diff --git a/web/src/lib/components/timeline/Photostream.svelte b/web/src/lib/components/timeline/Photostream.svelte new file mode 100644 index 0000000000..93425d03e7 --- /dev/null +++ b/web/src/lib/components/timeline/Photostream.svelte @@ -0,0 +1,290 @@ + + + { + // when hmr happens, skeleton is initialized to true by default + // normally, loading asset-grid is part of a navigation event, and the completion of + // that event triggers a scroll-to-asset, if necessary, when then clears the skeleton. + // this handler will run the navigation/scroll-to-asset handler when hmr is performed, + // preventing skeleton from showing after hmr + const finishHmr = () => { + const asset = $page.url.searchParams.get('at'); + if (asset) { + $gridScrollTarget = { at: asset }; + } + void completeNav(); + }; + const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte')); + if (assetGridUpdate) { + // wait 500ms for the update to be fully swapped in + setTimeout(finishHmr, 500); + } + }} +/> + +{@render header?.(scrollTo)} + + +
((timelineManager.viewportWidth = v), updateSlidingWindow())} + bind:this={element} + onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} +> +
+
+ {@render children?.()} + {#if isEmpty} + + {@render empty?.()} + {/if} +
+ + {#each timelineManager.months as monthGroup (monthGroup.id)} + {@const shouldDisplay = monthGroup.intersecting} + {@const absoluteHeight = monthGroup.top} +
+ {#if !shouldDisplay} + {@render skeleton({ segment: monthGroup })} + {:else} + {@render segment({ + segment: monthGroup, + scrollToFunction: scrollTo, + onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation, + })} + {/if} +
+ {/each} + +
+
+
+ + diff --git a/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte b/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte new file mode 100644 index 0000000000..23471f9616 --- /dev/null +++ b/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte @@ -0,0 +1,195 @@ + + + + {#snippet header(scrollToFunction)} + {#if timelineManager.months.length > 0} + onScrub({ ...scrubberData, scrollToFunction })} + bind:scrubberWidth + /> + {/if} + {/snippet} + diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index d4797ce5f4..5887b9b5ce 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -1,16 +1,12 @@ - - scrollToAsset(asset) ?? false} + scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false} {timelineManager} {assetInteraction} bind:isShowDeleteConfirmation {onEscape} /> -{#if timelineManager.months.length > 0} - -{/if} - - -
((timelineManager.viewportWidth = v), updateSlidingWindow())} - bind:this={element} - onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} + -
-
+ {/snippet} + {#snippet segment({ segment, onScrollCompensationMonthInDOM })} + - {@render children?.()} - {#if isEmpty} - - {@render empty?.()} - {/if} -
- - {#each timelineManager.months as monthGroup (monthGroup.viewId)} - {@const display = monthGroup.intersecting} - {@const absoluteHeight = monthGroup.top} - - {#if !monthGroup.isLoaded} -
- -
- {:else if display} -
- - {#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })} - - {#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })} - - {#snippet thumbnail({ asset, position, dayGroup, 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)} - onAssetOpen(asset)} - onSelect={() => onDayGroupAssetSelect(dayGroup, asset)} - onMouseEvent={(isMouseOver) => { - if (isMouseOver) { - onAssetHover(asset); - } else { - onAssetHover(null); - } - }} - selected={isAssetSelected} - selectionCandidate={isAssetSelectionCandidate} - disabled={isAssetDisabled} - thumbnailWidth={position.width} - thumbnailHeight={position.height} - /> - {/snippet} - - {/snippet} - - {/snippet} - -
- {/if} - {/each} - -
-
-
+ {#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })} + + {#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })} + + {#snippet thumbnail({ asset, position, dayGroup, 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)} + onAssetOpen(asset)} + onSelect={() => onDayGroupAssetSelect(dayGroup, asset)} + onMouseEvent={(isMouseOver) => { + if (isMouseOver) { + onAssetHover(asset); + } else { + onAssetHover(null); + } + }} + selected={isAssetSelected} + selectionCandidate={isAssetSelectionCandidate} + disabled={isAssetDisabled} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {/snippet} + + {/snippet} + + {/snippet} + + {/snippet} + {#if $showAssetViewer} {/if} - - diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts index 7e6ae734dc..191f5cf1ac 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts @@ -143,3 +143,79 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe } } } + +export interface MonthGroupForSearch { + yearMonth: TimelineYearMonth; + top: number; + height: number; +} + +export interface BinarySearchResult { + month: TimelineYearMonth; + monthScrollPercent: number; +} + +export function findMonthAtScrollPosition( + months: MonthGroupForSearch[], + scrollPosition: number, + maxScrollPercent: number, +): BinarySearchResult | null { + const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks + const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month + + if (months.length === 0) { + return null; + } + + // Check if we're before the first month + const firstMonthTop = months[0].top * maxScrollPercent; + if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) { + return null; + } + + // Check if we're after the last month + const lastMonth = months.at(-1)!; + const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent; + if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) { + return null; + } + + // Binary search to find the month containing the scroll position + let left = 0; + let right = months.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const month = months[mid]; + const monthTop = month.top * maxScrollPercent; + const monthBottom = monthTop + month.height * maxScrollPercent; + + if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) { + // Found the month containing the scroll position + const distanceIntoMonth = scrollPosition - monthTop; + const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); + + // Handle month boundary edge case + if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) { + return { + month: months[mid + 1].yearMonth, + monthScrollPercent: 0, + }; + } + + return { + month: month.yearMonth, + monthScrollPercent, + }; + } + + if (scrollPosition < monthTop) { + right = mid - 1; + } else { + left = mid + 1; + } + } + + // Shouldn't reach here, but return null if we do + return null; +} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 0a25040510..5d0e05d361 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -24,11 +24,19 @@ export type TimelineDateTime = TimelineDate & { millisecond: number; }; -export type ScrubberListener = (scrubberData: { +export type ScrubberData = { scrubberMonth: { year: number; month: number }; overallScrollPercent: number; scrubberMonthScrollPercent: number; -}) => void | Promise; +}; + +export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise; + +export type ScrubberDataWithScrollTo = ScrubberData & { + scrollToFunction: (top: number) => void; +}; + +export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise; // used for AssetResponseDto.dateTimeOriginal, amongst others export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime =>