From f61b7a8a158019bb425dae71740594486914b426 Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 6 Apr 2026 21:32:54 +0000 Subject: [PATCH] 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 --- .../components/timeline/AssetLayout.svelte | 1 - .../lib/components/timeline/Scrubber.svelte | 34 +- .../lib/components/timeline/Timeline.svelte | 303 +++++++++--------- .../VirtualScrollManager.svelte.ts | 21 +- .../internal/intersection-support.svelte.ts | 4 +- .../timeline-manager/timeline-day.svelte.ts | 2 +- .../timeline-manager.svelte.ts | 213 +++++++++++- .../timeline-manager/timeline-month.svelte.ts | 63 ++-- .../lib/managers/timeline-manager/types.ts | 1 - 9 files changed, 446 insertions(+), 196 deletions(-) diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 4ecf71f517..e0c895bd5a 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -47,7 +47,6 @@ style:inset-inline-start={position.left + 'px'} style:width={position.width + 'px'} style:height={position.height + 'px'} - out:scale|global={{ start: 0.1, duration: scaleDuration }} animate:flip={{ duration: transitionDuration }} > {@render thumbnail({ asset, position })} diff --git a/web/src/lib/components/timeline/Scrubber.svelte b/web/src/lib/components/timeline/Scrubber.svelte index 99aec0e429..0d3321f98f 100644 --- a/web/src/lib/components/timeline/Scrubber.svelte +++ b/web/src/lib/components/timeline/Scrubber.svelte @@ -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 @@ (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 })} /> diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index bcef9f3260..0847b11499 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -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())} >
{@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}
- -
- {:else if isInOrNearViewport} -
- - {#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)} - { - 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} - + {#if timelineMonth.isLoaded} +
+ + {#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)} + { + 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} + +
+ {:else} + + {/if}
{/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)`} >
diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts index fdbc86a7db..f77a08ff00 100644 --- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -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, diff --git a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts index bb3ae72c81..7a93a1275d 100644 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts @@ -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, ); diff --git a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts index ec4d0188ab..d0a1f40166 100644 --- a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts @@ -152,6 +152,6 @@ export class TimelineDay { } get absoluteTimelineDayTop() { - return this.timelineMonth.top + this.#top; + return this.timelineMonth.planeTop + this.#top; } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 215360b8f9..e711f668fa 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -70,9 +70,17 @@ export class TimelineManager extends VirtualScrollManager { months: TimelineMonth[] = $state([]); albumAssets: Set = 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 | 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 { diff --git a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts index 8d97a18131..0665ec5327 100644 --- a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts @@ -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, }; } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index c7b0d226d2..37c5e66f97 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -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;