mirror of
https://github.com/immich-app/immich.git
synced 2026-04-30 04:58:48 -07:00
Compare commits
1 Commits
chore/queu
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b049d0fa48 |
@@ -127,9 +127,27 @@
|
||||
const scrollY = $derived(
|
||||
toScrollFromTimelineMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||
);
|
||||
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
|
||||
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||
const estimateMonthHeight = (assetCount: number) => {
|
||||
const viewportWidth = timelineManager.viewportWidth;
|
||||
const rowHeight = timelineManager.rowHeight;
|
||||
const headerHeight = timelineManager.headerHeight;
|
||||
if (viewportWidth === 0) {
|
||||
return headerHeight + rowHeight;
|
||||
}
|
||||
const rows = Math.ceil(((3 / 2) * assetCount * rowHeight * (7 / 10)) / viewportWidth);
|
||||
return headerHeight + Math.max(1, rows) * rowHeight;
|
||||
};
|
||||
|
||||
const totalEstimatedHeight = $derived.by(() => {
|
||||
let total = timelineManager.topSectionHeight + timelineManager.bottomSectionHeight;
|
||||
for (const month of timelineManager.scrubberMonths) {
|
||||
total += estimateMonthHeight(month.assetCount);
|
||||
}
|
||||
return total;
|
||||
});
|
||||
|
||||
const relativeTopOffset = $derived(toScrollY(timelineManager.topSectionHeight / totalEstimatedHeight));
|
||||
const relativeBottomOffset = $derived(toScrollY(timelineManager.bottomSectionHeight / totalEstimatedHeight));
|
||||
|
||||
type Segment = {
|
||||
count: number;
|
||||
@@ -154,7 +172,7 @@
|
||||
const reversed = [...months].reverse();
|
||||
|
||||
for (const scrubMonth of reversed) {
|
||||
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
|
||||
const scrollBarPercentage = estimateMonthHeight(scrubMonth.assetCount) / totalEstimatedHeight;
|
||||
|
||||
const segment = {
|
||||
top,
|
||||
@@ -493,7 +511,13 @@
|
||||
|
||||
<svelte:window
|
||||
bind:innerHeight={windowHeight}
|
||||
onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
||||
onmousemove={(e) => {
|
||||
if (isDragging && (e.buttons & 1) === 0) {
|
||||
handleMouseEvent({ clientY: e.clientY, isDragging: false });
|
||||
} else if (isDragging || isHover) {
|
||||
handleMouseEvent({ clientY: e.clientY });
|
||||
}
|
||||
}}
|
||||
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||
/>
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
@@ -101,6 +102,14 @@
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const topSectionPlaneTop = $derived(
|
||||
timelineManager.months.length > 0 ? timelineManager.months[0].planeTop - timelineManager.topSectionHeight : 0,
|
||||
);
|
||||
const leadoutPlaneTop = $derived(
|
||||
timelineManager.months.length > 0
|
||||
? timelineManager.months.at(-1)!.planeTop + timelineManager.months.at(-1)!.height
|
||||
: timelineManager.topSectionHeight,
|
||||
);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
||||
|
||||
@@ -169,18 +178,15 @@
|
||||
|
||||
const scrollAndLoadAsset = async (assetId: string) => {
|
||||
try {
|
||||
// This flag prevents layout deferral to fix scroll positioning issues.
|
||||
// When layouts are deferred and we scroll to an asset at the end of the timeline,
|
||||
// we can calculate the asset's position, but the scrollableElement's scrollHeight
|
||||
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
|
||||
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
|
||||
// the performance benefits of deferred layouts while still supporting deep linking
|
||||
// to assets at the end of the timeline.
|
||||
timelineManager.isScrollingOnLoad = true;
|
||||
const timelineMonth = await timelineManager.findTimelineMonthForAsset({ id: assetId });
|
||||
if (!timelineMonth) {
|
||||
return false;
|
||||
}
|
||||
const monthIndex = timelineManager.months.indexOf(timelineMonth);
|
||||
if (monthIndex !== -1) {
|
||||
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: 0 });
|
||||
}
|
||||
scrollToAssetPosition(assetId, timelineMonth);
|
||||
return true;
|
||||
} finally {
|
||||
@@ -213,7 +219,6 @@
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
timelineManager.scrollTo(0);
|
||||
} else if (scrollTarget) {
|
||||
await tick();
|
||||
@@ -263,102 +268,105 @@
|
||||
}
|
||||
});
|
||||
|
||||
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, timelineMonthScrollPercent: number) => {
|
||||
const topOffset = segmentTop;
|
||||
const maxScrollPercent = timelineManager.maxScrollPercent;
|
||||
const delta = segmentHeight * timelineMonthScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
timelineManager.scrollTo(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;
|
||||
const { scrubberMonth, scrubberMonthScrollPercent } = scrubberData;
|
||||
|
||||
const leadIn = scrubberMonth === 'lead-in';
|
||||
const leadOut = scrubberMonth === 'lead-out';
|
||||
const noMonth = !scrubberMonth;
|
||||
// For small timelines, use linear percentage for bi-directional sync with handleTimelineScroll
|
||||
if (timelineManager.limitedScroll) {
|
||||
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
|
||||
timelineManager.scrollTo(scrubberData.overallScrollPercent * maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
if (noMonth || timelineManager.limitedScroll) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = timelineManager.maxScrollPercent;
|
||||
const offset = maxScroll * overallScrollPercent * timelineManager.totalViewerHeight;
|
||||
timelineManager.scrollTo(offset);
|
||||
} else if (leadIn) {
|
||||
scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent);
|
||||
} else if (leadOut) {
|
||||
scrollToSegmentPercentage(
|
||||
timelineManager.topSectionHeight + timelineManager.bodySectionHeight,
|
||||
timelineManager.bottomSectionHeight,
|
||||
scrubberMonthScrollPercent,
|
||||
);
|
||||
} else {
|
||||
const timelineMonth = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
);
|
||||
if (!timelineMonth) {
|
||||
if (!scrubberMonth) {
|
||||
if (timelineManager.months.length === 0) {
|
||||
return;
|
||||
}
|
||||
scrollToSegmentPercentage(timelineMonth.top, timelineMonth.height, scrubberMonthScrollPercent);
|
||||
if (scrubberData.overallScrollPercent <= 0) {
|
||||
const firstMonth = timelineManager.months[0];
|
||||
timelineManager.scrollTo(firstMonth.planeTop - timelineManager.topSectionHeight);
|
||||
} else if (scrubberData.overallScrollPercent >= 1) {
|
||||
const lastMonth = timelineManager.months.at(-1)!;
|
||||
timelineManager.scrollTo(
|
||||
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight - timelineManager.viewportHeight,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrubberMonth === 'lead-in') {
|
||||
if (timelineManager.months.length > 0) {
|
||||
const firstMonth = timelineManager.months[0];
|
||||
timelineManager.scrollTo(
|
||||
firstMonth.planeTop - timelineManager.topSectionHeight * (1 - scrubberMonthScrollPercent),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrubberMonth === 'lead-out') {
|
||||
if (timelineManager.months.length > 0) {
|
||||
const lastMonth = timelineManager.months.at(-1)!;
|
||||
timelineManager.scrollTo(
|
||||
lastMonth.planeTop + lastMonth.height + timelineManager.bottomSectionHeight * scrubberMonthScrollPercent,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const monthIndex = timelineManager.months.findIndex(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
);
|
||||
if (monthIndex !== -1) {
|
||||
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: scrubberMonthScrollPercent });
|
||||
}
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
const handleTimelineScroll = () => {
|
||||
if (!scrollableElement) {
|
||||
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
|
||||
timelineScrollPercent = maxScroll > 0 ? clamp(timelineManager.visibleWindow.top / maxScroll, 0, 1) : 0;
|
||||
|
||||
// For small timelines, use linear percentage positioning for smooth bi-directional sync
|
||||
if (timelineManager.limitedScroll) {
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (timelineManager.limitedScroll) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = timelineManager.maxScroll;
|
||||
|
||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||
const intersection = timelineManager.viewportTopMonthIntersection;
|
||||
if (!intersection?.month) {
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
} else {
|
||||
timelineScrollPercent = 0;
|
||||
|
||||
let top = scrollableElement.scrollTop;
|
||||
let maxScrollPercent = timelineManager.maxScrollPercent;
|
||||
|
||||
const monthsLength = timelineManager.months.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let timelineMonth: ViewportTopMonth;
|
||||
let timelineMonthHeight: number;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
timelineMonth = 'lead-in';
|
||||
timelineMonthHeight = timelineManager.topSectionHeight;
|
||||
} else if (i === monthsLength) {
|
||||
// lead-out
|
||||
timelineMonth = 'lead-out';
|
||||
timelineMonthHeight = timelineManager.bottomSectionHeight;
|
||||
} else {
|
||||
timelineMonth = timelineManager.months[i].yearMonth;
|
||||
timelineMonthHeight = timelineManager.months[i].height;
|
||||
}
|
||||
|
||||
let next = top - timelineMonthHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && timelineMonth) {
|
||||
viewportTopMonth = timelineMonth;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (timelineMonthHeight * 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;
|
||||
}
|
||||
break;
|
||||
}
|
||||
top = next;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const firstMonth = timelineManager.months[0];
|
||||
if (firstMonth && timelineManager.visibleWindow.top < firstMonth.planeTop) {
|
||||
viewportTopMonth = 'lead-in';
|
||||
const topSectionTop = firstMonth.planeTop - timelineManager.topSectionHeight;
|
||||
viewportTopMonthScrollPercent =
|
||||
timelineManager.topSectionHeight > 0
|
||||
? Math.max(0, (timelineManager.visibleWindow.top - topSectionTop) / timelineManager.topSectionHeight)
|
||||
: 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMonth = timelineManager.months.at(-1)!;
|
||||
const contentBottom = lastMonth.planeTop + lastMonth.height;
|
||||
if (timelineManager.visibleWindow.top >= contentBottom && timelineManager.bottomSectionHeight > 0) {
|
||||
viewportTopMonth = 'lead-out';
|
||||
viewportTopMonthScrollPercent = Math.min(
|
||||
1,
|
||||
(timelineManager.visibleWindow.bottom - contentBottom) / timelineManager.bottomSectionHeight,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
viewportTopMonth = intersection.month.yearMonth;
|
||||
viewportTopMonthScrollPercent = intersection.viewportTopRatioInMonth;
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
@@ -594,6 +602,8 @@
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
{onScrub}
|
||||
startScrub={() => timelineManager.setScrubbing(true)}
|
||||
stopScrub={() => timelineManager.setScrubbing(false)}
|
||||
bind:scrubberWidth
|
||||
onScrubKeyDown={(evt) => {
|
||||
evt.preventDefault();
|
||||
@@ -622,13 +632,13 @@
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
bind:this={scrollableElement}
|
||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||
onscroll={() => (timelineManager.updateSlidingWindow(), handleTimelineScroll(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible
|
||||
style:height={timelineManager.totalViewerHeight + 'px'}
|
||||
style:height={timelineManager.planeHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
bind:clientHeight={timelineManager.topSectionHeight}
|
||||
@@ -636,6 +646,7 @@
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${topSectionPlaneTop}px,0)`}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
@@ -646,70 +657,66 @@
|
||||
|
||||
{#each timelineManager.months as timelineMonth (timelineMonth.viewId)}
|
||||
{@const isInOrNearViewport = timelineMonth.isInOrNearViewport}
|
||||
{@const absoluteHeight = timelineMonth.top}
|
||||
{@const absoluteHeight = timelineMonth.planeTop}
|
||||
|
||||
{#if !timelineMonth.isLoaded}
|
||||
{#if isInOrNearViewport}
|
||||
<div
|
||||
style:height={timelineMonth.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
|
||||
</div>
|
||||
{:else if isInOrNearViewport}
|
||||
<div
|
||||
class="timeline-month"
|
||||
style:height={timelineMonth.height + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{timelineMonth}
|
||||
manager={timelineManager}
|
||||
onTimelineDaySelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
{@const isAssetSelected =
|
||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
||||
return;
|
||||
}
|
||||
void onSelectAssets(asset);
|
||||
}}
|
||||
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
||||
onPreview={isSelectionMode || assetInteraction.selectionActive
|
||||
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
|
||||
: undefined}
|
||||
selected={isAssetSelected}
|
||||
selectionCandidate={isAssetSelectionCandidate}
|
||||
disabled={isAssetDisabled}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{/snippet}
|
||||
</Month>
|
||||
{#if timelineMonth.isLoaded}
|
||||
<div class="timeline-month" style:height={timelineMonth.height + 'px'} style:width="100%">
|
||||
<Month
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{timelineMonth}
|
||||
manager={timelineManager}
|
||||
onTimelineDaySelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
{@const isAssetSelected =
|
||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
||||
return;
|
||||
}
|
||||
void onSelectAssets(asset);
|
||||
}}
|
||||
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
||||
onPreview={isSelectionMode || assetInteraction.selectionActive
|
||||
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
|
||||
: undefined}
|
||||
selected={isAssetSelected}
|
||||
selectionCandidate={isAssetSelectionCandidate}
|
||||
disabled={isAssetDisabled}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{/snippet}
|
||||
</Month>
|
||||
</div>
|
||||
{:else}
|
||||
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -719,7 +726,7 @@
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`}
|
||||
style:transform={`translate3d(0,${leadoutPlaneTop}px,0)`}
|
||||
></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -33,17 +33,7 @@
|
||||
:global(.dark) [data-skeleton] {
|
||||
background-image: url('/dark_skeleton.png');
|
||||
}
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
[data-skeleton] {
|
||||
visibility: hidden;
|
||||
animation:
|
||||
0s linear 0.1s forwards delayedVisibility,
|
||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.invisible [data-skeleton] {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,30 @@ type LayoutOptions = {
|
||||
gap: number;
|
||||
};
|
||||
export abstract class VirtualScrollManager {
|
||||
topSectionHeight = $state(0);
|
||||
static readonly PLANE_SIZE = 500_000;
|
||||
static readonly PLANE_CENTER = 250_000;
|
||||
|
||||
planeHeight = $state(VirtualScrollManager.PLANE_SIZE);
|
||||
#topSectionHeight = $state(0);
|
||||
bodySectionHeight = $state(0);
|
||||
bottomSectionHeight = $state(0);
|
||||
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
|
||||
|
||||
get topSectionHeight() {
|
||||
return this.#topSectionHeight;
|
||||
}
|
||||
|
||||
set topSectionHeight(value: number) {
|
||||
if (this.#topSectionHeight === value) {
|
||||
return;
|
||||
}
|
||||
const oldValue = this.#topSectionHeight;
|
||||
this.#topSectionHeight = value;
|
||||
this.onTopSectionHeightChanged(oldValue, value);
|
||||
}
|
||||
|
||||
protected onTopSectionHeightChanged(_oldHeight: number, _newHeight: number) {}
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
|
||||
@@ -42,8 +42,8 @@ function calculateViewportProximity(regionTop: number, regionBottom: number, win
|
||||
|
||||
export function updateTimelineMonthViewportProximity(timelineManager: TimelineManager, month: TimelineMonth) {
|
||||
const proximity = calculateViewportProximity(
|
||||
month.top,
|
||||
month.top + month.height,
|
||||
month.planeTop,
|
||||
month.planeTop + month.height,
|
||||
timelineManager.visibleWindow.top,
|
||||
timelineManager.visibleWindow.bottom,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function loadFromTimeBuckets(
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!bucketResponse) {
|
||||
if (!bucketResponse || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function loadFromTimeBuckets(
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (!albumAssets) {
|
||||
if (!albumAssets || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
for (const id of albumAssets.id) {
|
||||
|
||||
@@ -152,6 +152,6 @@ export class TimelineDay {
|
||||
}
|
||||
|
||||
get absoluteTimelineDayTop() {
|
||||
return this.timelineMonth.top + this.#top;
|
||||
return this.timelineMonth.planeTop + this.#top;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,17 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
months: TimelineMonth[] = $state([]);
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
||||
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||
anchorMonthIndex: number = -1;
|
||||
anchorPlaneTop: number = VirtualScrollManager.PLANE_CENTER;
|
||||
#recenterTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
#recentering = false;
|
||||
#scrubbing = false;
|
||||
limitedScroll = $derived(
|
||||
this.months.length > 0 &&
|
||||
this.totalViewerHeight <= VirtualScrollManager.PLANE_SIZE &&
|
||||
this.viewportHeight > this.months.at(-1)!.height + this.bottomSectionHeight,
|
||||
);
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
@@ -122,6 +130,22 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return this.#scrollableElement?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
protected override onTopSectionHeightChanged(oldHeight: number, newHeight: number) {
|
||||
if (this.anchorMonthIndex === -1 || this.months.length === 0) {
|
||||
return;
|
||||
}
|
||||
const delta = newHeight - oldHeight;
|
||||
const scrollTopBefore = this.scrollTop;
|
||||
this.anchorPlaneTop += delta;
|
||||
this.positionMonthsOnPlane();
|
||||
// If the user is still inside the lead-in, no month content is visible to keep
|
||||
// pinned, and shifting scrollTop would push them past the lead-in.
|
||||
if (scrollTopBefore <= oldHeight) {
|
||||
return;
|
||||
}
|
||||
this.scrollBy(delta);
|
||||
}
|
||||
|
||||
set scrollableElement(element: HTMLElement | undefined) {
|
||||
this.#scrollableElement = element;
|
||||
}
|
||||
@@ -189,7 +213,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return 0;
|
||||
}
|
||||
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
||||
const bottomOfMonth = month.top + month.height;
|
||||
const bottomOfMonth = month.planeTop + month.height;
|
||||
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
|
||||
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
||||
}
|
||||
@@ -198,7 +222,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
|
||||
return clamp((this.visibleWindow.top - month.planeTop) / month.height, 0, 1);
|
||||
}
|
||||
|
||||
override updateViewportProximities() {
|
||||
@@ -238,6 +262,177 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives every month's planeTop by walking outward from the anchor. The anchor
|
||||
* stays pinned at anchorPlaneTop, so any height change elsewhere shifts months
|
||||
* away from the anchor — content at the viewport-top stays stable as long as
|
||||
* trackAnchorToViewportTop ran beforehand.
|
||||
*/
|
||||
positionMonthsOnPlane() {
|
||||
if (this.months.length === 0 || this.anchorMonthIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const anchor = this.months[this.anchorMonthIndex];
|
||||
anchor.planeTop = this.anchorPlaneTop;
|
||||
|
||||
let cursorBelow = this.anchorPlaneTop + anchor.height;
|
||||
for (let i = this.anchorMonthIndex + 1; i < this.months.length; i++) {
|
||||
const month = this.months[i];
|
||||
month.planeTop = cursorBelow;
|
||||
cursorBelow += month.height;
|
||||
}
|
||||
|
||||
let cursorAbove = this.anchorPlaneTop;
|
||||
for (let i = this.anchorMonthIndex - 1; i >= 0; i--) {
|
||||
const month = this.months[i];
|
||||
cursorAbove -= month.height;
|
||||
month.planeTop = cursorAbove;
|
||||
}
|
||||
|
||||
const lastMonth = this.months.at(-1)!;
|
||||
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
|
||||
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
|
||||
}
|
||||
|
||||
/** Soft repoint: change the anchor month without moving any planeTop or scrollTop. */
|
||||
trackAnchorToViewportTop() {
|
||||
if (this.months.length === 0) {
|
||||
return;
|
||||
}
|
||||
const visibleTop = this.visibleWindow.top;
|
||||
let newAnchorIndex = -1;
|
||||
for (let i = 0; i < this.months.length; i++) {
|
||||
const month = this.months[i];
|
||||
if (month.planeTop + month.height > visibleTop) {
|
||||
newAnchorIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newAnchorIndex === -1 || newAnchorIndex === this.anchorMonthIndex) {
|
||||
return;
|
||||
}
|
||||
this.anchorMonthIndex = newAnchorIndex;
|
||||
this.anchorPlaneTop = this.months[newAnchorIndex].planeTop;
|
||||
}
|
||||
|
||||
// Each scroll event resets this timer, so a brief pause in scrolling recenters
|
||||
// the plane. Continuous scrolling near a plane edge bypasses it via isNearPlaneEdge.
|
||||
static readonly RECENTER_DEBOUNCE_MS = 50;
|
||||
static readonly PLANE_EDGE_THRESHOLD = 50_000;
|
||||
|
||||
#scheduleRecenter() {
|
||||
clearTimeout(this.#recenterTimer);
|
||||
this.#recenterTimer = setTimeout(() => {
|
||||
this.#recenterTimer = undefined;
|
||||
this.recenterPlane();
|
||||
}, TimelineManager.RECENTER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
isNearPlaneEdge(): boolean {
|
||||
return (
|
||||
this.scrollTop < TimelineManager.PLANE_EDGE_THRESHOLD ||
|
||||
this.scrollTop > VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard repoint: slide every planeTop and scrollTop to pull the anchor back
|
||||
* toward PLANE_CENTER, or pin month 0 to topSectionHeight when it fits.
|
||||
*/
|
||||
recenterPlane() {
|
||||
clearTimeout(this.#recenterTimer);
|
||||
this.#recenterTimer = undefined;
|
||||
if (this.#recentering || this.months.length === 0 || this.anchorMonthIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const viewportTopMonth = this.months[this.anchorMonthIndex];
|
||||
if (!viewportTopMonth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pin months[0] when the visible content still fits on the plane alongside it.
|
||||
// Only the downward distance is checked because nothing exists above month 0.
|
||||
// Fall back to PLANE_CENTER recycling only when month 0 no longer fits.
|
||||
const firstMonth = this.months[0];
|
||||
const viewportTopOffsetFromFirstMonth = viewportTopMonth.planeTop - firstMonth.planeTop + this.topSectionHeight;
|
||||
const canPinFirstMonth =
|
||||
viewportTopOffsetFromFirstMonth + this.viewportHeight <=
|
||||
VirtualScrollManager.PLANE_SIZE - TimelineManager.PLANE_EDGE_THRESHOLD;
|
||||
|
||||
let targetMonth: TimelineMonth;
|
||||
let targetPlaneTop: number;
|
||||
if (canPinFirstMonth || this.anchorMonthIndex === 0) {
|
||||
targetMonth = firstMonth;
|
||||
targetPlaneTop = this.topSectionHeight;
|
||||
} else {
|
||||
targetMonth = viewportTopMonth;
|
||||
targetPlaneTop = VirtualScrollManager.PLANE_CENTER;
|
||||
}
|
||||
const monthIndex = this.months.indexOf(targetMonth);
|
||||
const delta = targetPlaneTop - targetMonth.planeTop;
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
// Same lead-in guard as onTopSectionHeightChanged.
|
||||
const preserveScrollTop = this.scrollTop <= this.topSectionHeight;
|
||||
this.#recentering = true;
|
||||
try {
|
||||
for (const month of this.months) {
|
||||
month.planeTop += delta;
|
||||
}
|
||||
this.anchorMonthIndex = monthIndex;
|
||||
this.anchorPlaneTop = targetPlaneTop;
|
||||
if (this.#scrollableElement && !preserveScrollTop) {
|
||||
this.#scrollableElement.scrollTop += delta;
|
||||
}
|
||||
const lastMonth = this.months.at(-1)!;
|
||||
const contentBottom = lastMonth.planeTop + lastMonth.height + this.bottomSectionHeight;
|
||||
this.planeHeight = Math.min(VirtualScrollManager.PLANE_SIZE, contentBottom);
|
||||
this.updateSlidingWindow();
|
||||
} finally {
|
||||
this.#recentering = false;
|
||||
}
|
||||
}
|
||||
|
||||
override updateSlidingWindow() {
|
||||
super.updateSlidingWindow();
|
||||
if (this.#recentering || this.#scrubbing) {
|
||||
return;
|
||||
}
|
||||
this.trackAnchorToViewportTop();
|
||||
|
||||
// Continuous scroll keeps resetting the debounce timer, so if scrollTop is
|
||||
// already near a plane edge we have to recenter immediately or risk hitting it.
|
||||
if (this.isNearPlaneEdge()) {
|
||||
this.recenterPlane();
|
||||
return;
|
||||
}
|
||||
this.#scheduleRecenter();
|
||||
}
|
||||
|
||||
setScrubbing(value: boolean) {
|
||||
this.#scrubbing = value;
|
||||
if (!value) {
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
}
|
||||
|
||||
jumpToMonth({ monthIndex, fractionInMonth }: { monthIndex: number; fractionInMonth: number }) {
|
||||
clearTimeout(this.#recenterTimer);
|
||||
this.#recenterTimer = undefined;
|
||||
if (this.months.length === 0) {
|
||||
return;
|
||||
}
|
||||
const month = this.months[monthIndex];
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
this.anchorMonthIndex = monthIndex;
|
||||
this.anchorPlaneTop = monthIndex === 0 ? this.topSectionHeight : VirtualScrollManager.PLANE_CENTER;
|
||||
this.positionMonthsOnPlane();
|
||||
this.scrollTo(month.planeTop + fractionInMonth * month.height);
|
||||
}
|
||||
|
||||
async #initializeTimelineMonths() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...authManager.params,
|
||||
@@ -280,6 +475,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
this.isInitialized = false;
|
||||
this.months = [];
|
||||
this.anchorMonthIndex = -1;
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
@@ -324,6 +520,13 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||
}
|
||||
|
||||
if (this.months.length > 0 && this.anchorMonthIndex === -1) {
|
||||
this.anchorMonthIndex = 0;
|
||||
this.anchorPlaneTop = this.topSectionHeight;
|
||||
}
|
||||
this.positionMonthsOnPlane();
|
||||
|
||||
this.updateViewportProximities();
|
||||
if (changedWidth) {
|
||||
this.#createScrubberMonths();
|
||||
@@ -336,9 +539,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
title: month.title,
|
||||
height: month.height,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
||||
}
|
||||
|
||||
async loadTimelineMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
|
||||
@@ -36,7 +36,7 @@ export class TimelineMonth {
|
||||
readonly timelineManager: TimelineManager;
|
||||
|
||||
#height: number = $state(0);
|
||||
#top: number = $state(0);
|
||||
#planeTop: number = $state(0);
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
@@ -266,39 +266,36 @@ export class TimelineMonth {
|
||||
return;
|
||||
}
|
||||
const timelineManager = this.timelineManager;
|
||||
const index = timelineManager.months.indexOf(this);
|
||||
// Repin the anchor BEFORE positionMonthsOnPlane re-derives planeTops, so the
|
||||
// recomputation only shifts content above the viewport (invisible to the user).
|
||||
timelineManager.trackAnchorToViewportTop();
|
||||
// When this month is the viewport-top one, its photos will reflow as the height
|
||||
// settles from estimate to actual; capture the user's fractional position so we
|
||||
// can restore it below and avoid the visible stutter.
|
||||
const isViewportTopMonth =
|
||||
timelineManager.anchorMonthIndex !== -1 &&
|
||||
timelineManager.months[timelineManager.anchorMonthIndex] === this &&
|
||||
this.#height > 0;
|
||||
const scrollFractionInMonth = isViewportTopMonth ? (timelineManager.scrollTop - this.#planeTop) / this.#height : 0;
|
||||
const heightDelta = height - this.#height;
|
||||
this.#height = height;
|
||||
const previousTimelineMonth = timelineManager.months[index - 1];
|
||||
if (previousTimelineMonth) {
|
||||
const newTop = previousTimelineMonth.#top + previousTimelineMonth.#height;
|
||||
if (this.#top !== newTop) {
|
||||
this.#top = newTop;
|
||||
}
|
||||
}
|
||||
|
||||
if (heightDelta === 0) {
|
||||
return;
|
||||
}
|
||||
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
|
||||
const timelineMonth = this.timelineManager.months[cursor];
|
||||
const newTop = timelineMonth.#top + heightDelta;
|
||||
if (timelineMonth.#top !== newTop) {
|
||||
timelineMonth.#top = newTop;
|
||||
}
|
||||
|
||||
// Shift the anchor instead of scrollTop — touching scrollTop here fights
|
||||
// native scroll momentum on Safari and visibly stutters.
|
||||
if (isViewportTopMonth && scrollFractionInMonth > 0) {
|
||||
timelineManager.anchorPlaneTop -= heightDelta * scrollFractionInMonth;
|
||||
}
|
||||
if (!timelineManager.viewportTopMonthIntersection) {
|
||||
return;
|
||||
}
|
||||
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
|
||||
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
|
||||
if (!month || currentIndex <= 0 || index > currentIndex) {
|
||||
return;
|
||||
}
|
||||
if (index < currentIndex || monthBottomViewportRatio < 1) {
|
||||
timelineManager.scrollBy(heightDelta);
|
||||
} else if (index === currentIndex) {
|
||||
const scrollTo = this.top + height * viewportTopRatioInMonth;
|
||||
timelineManager.scrollTo(scrollTo);
|
||||
|
||||
timelineManager.positionMonthsOnPlane();
|
||||
|
||||
// Async loads change heights without going through updateSlidingWindow, so the
|
||||
// near-edge check needs to run here too.
|
||||
if (timelineManager.isNearPlaneEdge()) {
|
||||
timelineManager.recenterPlane();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,8 +303,12 @@ export class TimelineMonth {
|
||||
return this.#height;
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.#top + this.timelineManager.topSectionHeight;
|
||||
get planeTop(): number {
|
||||
return this.#planeTop;
|
||||
}
|
||||
|
||||
set planeTop(value: number) {
|
||||
this.#planeTop = value;
|
||||
}
|
||||
|
||||
#handleLoadError(error: unknown) {
|
||||
@@ -337,7 +338,7 @@ export class TimelineMonth {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
|
||||
top: this.planeTop + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
|
||||
height: viewerAsset.position.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface UpdateStackAssets {
|
||||
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||
|
||||
export type ScrubberMonth = {
|
||||
height: number;
|
||||
assetCount: number;
|
||||
year: number;
|
||||
month: number;
|
||||
|
||||
@@ -64,8 +64,12 @@ export class CancellableTask {
|
||||
if (this.cancellable && !cancellable) {
|
||||
this.cancellable = cancellable;
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
try {
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
return 'CANCELED';
|
||||
}
|
||||
}
|
||||
this.cancellable = cancellable;
|
||||
const cancelToken = (this.cancelToken = new AbortController());
|
||||
|
||||
Reference in New Issue
Block a user