Compare commits

...

1 Commits

Author SHA1 Message Date
midzelis
b049d0fa48 refactor(web): fixed-size scroll plane for timeline virtual scroll
Replaces the timeline's growing virtual scroll height with a fixed
500K-pixel scroll plane that recycles around an anchor month. Removes
the browser max-height ceiling and the O(N) layout cascade that ran on
every month height change.

- Months are positioned by planeTop, derived on demand by walking
  outward from the anchor in positionMonthsOnPlane.
- Soft repoint (trackAnchorToViewportTop) runs on every scroll; hard
  repoint (recenterPlane) slides the plane back toward PLANE_CENTER on
  idle or near plane edges.
- Height changes shift the anchor instead of scrollTop, fixing Safari
  momentum-scroll stutter when a viewport-top month settles.

Change-Id: I39cb61e7c4ff6cd5b0d59a7cc9c65b4e6a6a6964
2026-04-07 13:55:10 +00:00
11 changed files with 455 additions and 210 deletions

View File

@@ -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 })}
/>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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) {

View File

@@ -152,6 +152,6 @@ export class TimelineDay {
}
get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top;
return this.timelineMonth.planeTop + this.#top;
}
}

View File

@@ -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> {

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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());