mirror of
https://github.com/immich-app/immich.git
synced 2026-05-20 23:02:32 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f61b7a8a15 |
@@ -47,7 +47,6 @@
|
|||||||
style:inset-inline-start={position.left + 'px'}
|
style:inset-inline-start={position.left + 'px'}
|
||||||
style:width={position.width + 'px'}
|
style:width={position.width + 'px'}
|
||||||
style:height={position.height + 'px'}
|
style:height={position.height + 'px'}
|
||||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
|
||||||
animate:flip={{ duration: transitionDuration }}
|
animate:flip={{ duration: transitionDuration }}
|
||||||
>
|
>
|
||||||
{@render thumbnail({ asset, position })}
|
{@render thumbnail({ asset, position })}
|
||||||
|
|||||||
@@ -127,9 +127,27 @@
|
|||||||
const scrollY = $derived(
|
const scrollY = $derived(
|
||||||
toScrollFromTimelineMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
toScrollFromTimelineMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||||
);
|
);
|
||||||
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
|
const estimateMonthHeight = (assetCount: number) => {
|
||||||
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
const viewportWidth = timelineManager.viewportWidth;
|
||||||
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
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 = {
|
type Segment = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -154,7 +172,7 @@
|
|||||||
const reversed = [...months].reverse();
|
const reversed = [...months].reverse();
|
||||||
|
|
||||||
for (const scrubMonth of reversed) {
|
for (const scrubMonth of reversed) {
|
||||||
const scrollBarPercentage = scrubMonth.height / timelineFullHeight;
|
const scrollBarPercentage = estimateMonthHeight(scrubMonth.assetCount) / totalEstimatedHeight;
|
||||||
|
|
||||||
const segment = {
|
const segment = {
|
||||||
top,
|
top,
|
||||||
@@ -493,7 +511,13 @@
|
|||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
bind:innerHeight={windowHeight}
|
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 })}
|
onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,16 +13,17 @@
|
|||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-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 { 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 { 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 type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { clamp } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
@@ -101,6 +102,14 @@
|
|||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 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 maxMd = $derived(mediaQueryManager.maxMd);
|
||||||
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
|
||||||
|
|
||||||
@@ -169,18 +178,15 @@
|
|||||||
|
|
||||||
const scrollAndLoadAsset = async (assetId: string) => {
|
const scrollAndLoadAsset = async (assetId: string) => {
|
||||||
try {
|
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;
|
timelineManager.isScrollingOnLoad = true;
|
||||||
const timelineMonth = await timelineManager.findTimelineMonthForAsset({ id: assetId });
|
const timelineMonth = await timelineManager.findTimelineMonthForAsset({ id: assetId });
|
||||||
if (!timelineMonth) {
|
if (!timelineMonth) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const monthIndex = timelineManager.months.indexOf(timelineMonth);
|
||||||
|
if (monthIndex !== -1) {
|
||||||
|
timelineManager.jumpToMonth({ monthIndex, fractionInMonth: 0 });
|
||||||
|
}
|
||||||
scrollToAssetPosition(assetId, timelineMonth);
|
scrollToAssetPosition(assetId, timelineMonth);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -213,7 +219,6 @@
|
|||||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||||
}
|
}
|
||||||
if (!scrolled) {
|
if (!scrolled) {
|
||||||
// if the asset is not found, scroll to the top
|
|
||||||
timelineManager.scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
} else if (scrollTarget) {
|
} else if (scrollTarget) {
|
||||||
await tick();
|
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
|
// 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
|
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||||
const onScrub: ScrubberListener = (scrubberData) => {
|
const onScrub: ScrubberListener = (scrubberData) => {
|
||||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
const { scrubberMonth, scrubberMonthScrollPercent } = scrubberData;
|
||||||
|
|
||||||
const leadIn = scrubberMonth === 'lead-in';
|
// For small timelines, use linear percentage for bi-directional sync with handleTimelineScroll
|
||||||
const leadOut = scrubberMonth === 'lead-out';
|
if (timelineManager.limitedScroll) {
|
||||||
const noMonth = !scrubberMonth;
|
const maxScroll = timelineManager.planeHeight - timelineManager.viewportHeight;
|
||||||
|
timelineManager.scrollTo(scrubberData.overallScrollPercent * maxScroll);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (noMonth || timelineManager.limitedScroll) {
|
if (!scrubberMonth) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
if (timelineManager.months.length === 0) {
|
||||||
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) {
|
|
||||||
return;
|
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
|
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||||
const handleTimelineScroll = () => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.limitedScroll) {
|
const intersection = timelineManager.viewportTopMonthIntersection;
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
if (!intersection?.month) {
|
||||||
const maxScroll = timelineManager.maxScroll;
|
|
||||||
|
|
||||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
} else {
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||||
@@ -594,6 +602,8 @@
|
|||||||
{viewportTopMonthScrollPercent}
|
{viewportTopMonthScrollPercent}
|
||||||
{viewportTopMonth}
|
{viewportTopMonth}
|
||||||
{onScrub}
|
{onScrub}
|
||||||
|
startScrub={() => timelineManager.setScrubbing(true)}
|
||||||
|
stopScrub={() => timelineManager.setScrubbing(false)}
|
||||||
bind:scrubberWidth
|
bind:scrubberWidth
|
||||||
onScrubKeyDown={(evt) => {
|
onScrubKeyDown={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -622,13 +632,13 @@
|
|||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={timelineManager.viewportWidth}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
bind:this={scrollableElement}
|
bind:this={scrollableElement}
|
||||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (timelineManager.updateSlidingWindow(), handleTimelineScroll(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible
|
class:invisible
|
||||||
style:height={timelineManager.totalViewerHeight + 'px'}
|
style:height={timelineManager.planeHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:clientHeight={timelineManager.topSectionHeight}
|
bind:clientHeight={timelineManager.topSectionHeight}
|
||||||
@@ -636,6 +646,7 @@
|
|||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
|
style:transform={`translate3d(0,${topSectionPlaneTop}px,0)`}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
@@ -646,70 +657,66 @@
|
|||||||
|
|
||||||
{#each timelineManager.months as timelineMonth (timelineMonth.viewId)}
|
{#each timelineManager.months as timelineMonth (timelineMonth.viewId)}
|
||||||
{@const isInOrNearViewport = timelineMonth.isInOrNearViewport}
|
{@const isInOrNearViewport = timelineMonth.isInOrNearViewport}
|
||||||
{@const absoluteHeight = timelineMonth.top}
|
{@const absoluteHeight = timelineMonth.planeTop}
|
||||||
|
|
||||||
{#if !timelineMonth.isLoaded}
|
{#if isInOrNearViewport}
|
||||||
<div
|
<div
|
||||||
style:height={timelineMonth.height + 'px'}
|
style:height={timelineMonth.height + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
|
{#if timelineMonth.isLoaded}
|
||||||
</div>
|
<div class="timeline-month" style:height={timelineMonth.height + 'px'} style:width="100%">
|
||||||
{:else if isInOrNearViewport}
|
<Month
|
||||||
<div
|
{assetInteraction}
|
||||||
class="timeline-month"
|
{customThumbnailLayout}
|
||||||
style:height={timelineMonth.height + 'px'}
|
{singleSelect}
|
||||||
style:position="absolute"
|
{timelineMonth}
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
manager={timelineManager}
|
||||||
style:width="100%"
|
onTimelineDaySelect={handleGroupSelect}
|
||||||
>
|
>
|
||||||
<Month
|
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
||||||
{assetInteraction}
|
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
{customThumbnailLayout}
|
{@const isAssetSelected =
|
||||||
{singleSelect}
|
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||||
{timelineMonth}
|
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||||
manager={timelineManager}
|
<Thumbnail
|
||||||
onTimelineDaySelect={handleGroupSelect}
|
showStackedIcon={withStacked}
|
||||||
>
|
{showArchiveIcon}
|
||||||
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
{asset}
|
||||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
{albumUsers}
|
||||||
{@const isAssetSelected =
|
{groupIndex}
|
||||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
onClick={(asset) => {
|
||||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
if (typeof onThumbnailClick === 'function') {
|
||||||
<Thumbnail
|
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
||||||
showStackedIcon={withStacked}
|
} else {
|
||||||
{showArchiveIcon}
|
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||||
{asset}
|
}
|
||||||
{albumUsers}
|
}}
|
||||||
{groupIndex}
|
onSelect={() => {
|
||||||
onClick={(asset) => {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
if (typeof onThumbnailClick === 'function') {
|
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
||||||
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
return;
|
||||||
} else {
|
}
|
||||||
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
void onSelectAssets(asset);
|
||||||
}
|
}}
|
||||||
}}
|
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
||||||
onSelect={() => {
|
onPreview={isSelectionMode || assetInteraction.selectionActive
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
|
||||||
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
: undefined}
|
||||||
return;
|
selected={isAssetSelected}
|
||||||
}
|
selectionCandidate={isAssetSelectionCandidate}
|
||||||
void onSelectAssets(asset);
|
disabled={isAssetDisabled}
|
||||||
}}
|
thumbnailWidth={position.width}
|
||||||
onMouseEvent={() => handleSelectAssetCandidates(asset)}
|
thumbnailHeight={position.height}
|
||||||
onPreview={isSelectionMode || assetInteraction.selectionActive
|
/>
|
||||||
? (asset) => void navigate({ targetRoute: 'current', assetId: asset.id })
|
{/snippet}
|
||||||
: undefined}
|
</Month>
|
||||||
selected={isAssetSelected}
|
</div>
|
||||||
selectionCandidate={isAssetSelectionCandidate}
|
{:else}
|
||||||
disabled={isAssetDisabled}
|
<Skeleton {invisible} height={timelineMonth.height} title={timelineMonth.title} />
|
||||||
thumbnailWidth={position.width}
|
{/if}
|
||||||
thumbnailHeight={position.height}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</Month>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -719,7 +726,7 @@
|
|||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`}
|
style:transform={`translate3d(0,${leadoutPlaneTop}px,0)`}
|
||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,11 +6,30 @@ type LayoutOptions = {
|
|||||||
gap: number;
|
gap: number;
|
||||||
};
|
};
|
||||||
export abstract class VirtualScrollManager {
|
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);
|
bodySectionHeight = $state(0);
|
||||||
bottomSectionHeight = $state(0);
|
bottomSectionHeight = $state(0);
|
||||||
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
|
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(() => ({
|
visibleWindow = $derived.by(() => ({
|
||||||
top: this.#scrollTop,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ function calculateViewportProximity(regionTop: number, regionBottom: number, win
|
|||||||
|
|
||||||
export function updateTimelineMonthViewportProximity(timelineManager: TimelineManager, month: TimelineMonth) {
|
export function updateTimelineMonthViewportProximity(timelineManager: TimelineManager, month: TimelineMonth) {
|
||||||
const proximity = calculateViewportProximity(
|
const proximity = calculateViewportProximity(
|
||||||
month.top,
|
month.planeTop,
|
||||||
month.top + month.height,
|
month.planeTop + month.height,
|
||||||
timelineManager.visibleWindow.top,
|
timelineManager.visibleWindow.top,
|
||||||
timelineManager.visibleWindow.bottom,
|
timelineManager.visibleWindow.bottom,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -152,6 +152,6 @@ export class TimelineDay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get absoluteTimelineDayTop() {
|
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([]);
|
months: TimelineMonth[] = $state([]);
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
|
||||||
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
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(
|
initTask = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
@@ -122,6 +130,22 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
return this.#scrollableElement?.scrollTop ?? 0;
|
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) {
|
set scrollableElement(element: HTMLElement | undefined) {
|
||||||
this.#scrollableElement = element;
|
this.#scrollableElement = element;
|
||||||
}
|
}
|
||||||
@@ -189,7 +213,7 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
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;
|
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
|
||||||
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
||||||
}
|
}
|
||||||
@@ -198,7 +222,7 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
if (!month) {
|
if (!month) {
|
||||||
return 0;
|
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() {
|
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() {
|
async #initializeTimelineMonths() {
|
||||||
const timebuckets = await getTimeBuckets({
|
const timebuckets = await getTimeBuckets({
|
||||||
...authManager.params,
|
...authManager.params,
|
||||||
@@ -280,6 +475,7 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
async #init(options: TimelineManagerOptions) {
|
async #init(options: TimelineManagerOptions) {
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.months = [];
|
this.months = [];
|
||||||
|
this.anchorMonthIndex = -1;
|
||||||
this.albumAssets.clear();
|
this.albumAssets.clear();
|
||||||
await this.initTask.execute(async () => {
|
await this.initTask.execute(async () => {
|
||||||
this.#options = options;
|
this.#options = options;
|
||||||
@@ -324,6 +520,13 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.months.length > 0 && this.anchorMonthIndex === -1) {
|
||||||
|
this.anchorMonthIndex = 0;
|
||||||
|
this.anchorPlaneTop = this.topSectionHeight;
|
||||||
|
}
|
||||||
|
this.positionMonthsOnPlane();
|
||||||
|
|
||||||
this.updateViewportProximities();
|
this.updateViewportProximities();
|
||||||
if (changedWidth) {
|
if (changedWidth) {
|
||||||
this.#createScrubberMonths();
|
this.#createScrubberMonths();
|
||||||
@@ -336,9 +539,7 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
year: month.yearMonth.year,
|
year: month.yearMonth.year,
|
||||||
month: month.yearMonth.month,
|
month: month.yearMonth.month,
|
||||||
title: month.title,
|
title: month.title,
|
||||||
height: month.height,
|
|
||||||
}));
|
}));
|
||||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTimelineMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
async loadTimelineMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class TimelineMonth {
|
|||||||
readonly timelineManager: TimelineManager;
|
readonly timelineManager: TimelineManager;
|
||||||
|
|
||||||
#height: number = $state(0);
|
#height: number = $state(0);
|
||||||
#top: number = $state(0);
|
#planeTop: number = $state(0);
|
||||||
|
|
||||||
#initialCount: number = 0;
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
@@ -266,39 +266,36 @@ export class TimelineMonth {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timelineManager = this.timelineManager;
|
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;
|
const heightDelta = height - this.#height;
|
||||||
this.#height = 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) {
|
if (heightDelta === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
|
|
||||||
const timelineMonth = this.timelineManager.months[cursor];
|
// Shift the anchor instead of scrollTop — touching scrollTop here fights
|
||||||
const newTop = timelineMonth.#top + heightDelta;
|
// native scroll momentum on Safari and visibly stutters.
|
||||||
if (timelineMonth.#top !== newTop) {
|
if (isViewportTopMonth && scrollFractionInMonth > 0) {
|
||||||
timelineMonth.#top = newTop;
|
timelineManager.anchorPlaneTop -= heightDelta * scrollFractionInMonth;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!timelineManager.viewportTopMonthIntersection) {
|
|
||||||
return;
|
timelineManager.positionMonthsOnPlane();
|
||||||
}
|
|
||||||
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
|
// Async loads change heights without going through updateSlidingWindow, so the
|
||||||
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
|
// near-edge check needs to run here too.
|
||||||
if (!month || currentIndex <= 0 || index > currentIndex) {
|
if (timelineManager.isNearPlaneEdge()) {
|
||||||
return;
|
timelineManager.recenterPlane();
|
||||||
}
|
|
||||||
if (index < currentIndex || monthBottomViewportRatio < 1) {
|
|
||||||
timelineManager.scrollBy(heightDelta);
|
|
||||||
} else if (index === currentIndex) {
|
|
||||||
const scrollTo = this.top + height * viewportTopRatioInMonth;
|
|
||||||
timelineManager.scrollTo(scrollTo);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,8 +303,12 @@ export class TimelineMonth {
|
|||||||
return this.#height;
|
return this.#height;
|
||||||
}
|
}
|
||||||
|
|
||||||
get top(): number {
|
get planeTop(): number {
|
||||||
return this.#top + this.timelineManager.topSectionHeight;
|
return this.#planeTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
set planeTop(value: number) {
|
||||||
|
this.#planeTop = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleLoadError(error: unknown) {
|
#handleLoadError(error: unknown) {
|
||||||
@@ -337,7 +338,7 @@ export class TimelineMonth {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
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,
|
height: viewerAsset.position.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export interface UpdateStackAssets {
|
|||||||
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||||
|
|
||||||
export type ScrubberMonth = {
|
export type ScrubberMonth = {
|
||||||
height: number;
|
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user