diff --git a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte
index ddcc66904f..7c9d5eeed0 100644
--- a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte
+++ b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte
@@ -292,7 +292,7 @@
{/if}
{/each}
-
+
monthsLength + 1; // +1 for lead-out
+
+ let isInLeadOutSection = $state(false);
+ // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
+ // Note: There may be multiple months visible within the viewport at any given time.
+ let viewportTopMonthScrollPercent = $state(0);
+ let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined);
+ let timelineScrollPercent: number = $state(0);
let scrubberWidth: number = $state(0);
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function updates the scrubber position based on the current scroll position in the timeline
const handleTimelineScroll = () => {
- leadOut = false;
+ isInLeadOutSection = false;
// Handle small timeline edge case: scroll limited due to size of content
if (isSmallTimeline()) {
@@ -80,111 +98,131 @@
};
const isSmallTimeline = () => {
- return timelineManager.timelineHeight < timelineManager.viewportHeight * 2;
+ return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER;
+ };
+
+ const isNearMonthBoundary = (progress: number) => {
+ return progress > NEAR_END_THRESHOLD;
+ };
+
+ const canAdvanceToNextMonth = (currentIndex: number, monthsLength: number) => {
+ return currentIndex + 1 < monthsLength - 1;
};
const resetScrubberMonth = () => {
- scrubberMonth = undefined;
- scrubberMonthPercent = 0;
+ viewportTopMonth = undefined;
+ viewportTopMonthScrollPercent = 0;
+ };
+
+ const calculateTimelineScrollPercent = () => {
+ const maxScroll = timelineManager.getMaxScroll();
+ timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
+ resetScrubberMonth();
};
const handleSmallTimelineScroll = () => {
- const maxScroll = timelineManager.getMaxScroll();
- scrubOverallPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
- resetScrubberMonth();
+ calculateTimelineScrollPercent();
};
const handleLeadInScroll = () => {
- const maxScroll = timelineManager.getMaxScroll();
- scrubOverallPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
- resetScrubberMonth();
+ calculateTimelineScrollPercent();
};
const handleMonthScroll = () => {
const monthsLength = timelineManager.months.length;
const maxScrollPercent = timelineManager.getMaxScrollPercent();
- let top = timelineManager.visibleWindow.top;
- let found = false;
+ let remainingScrollDistance = timelineManager.visibleWindow.top;
+ // Tracks if we found the month intersecting the viewport top
+ let foundIntersectingMonth = false;
- for (let i = -1; i < monthsLength + 1; i++) {
- const monthData = getMonthData(i);
- const next = top - monthData.height * maxScrollPercent;
+ for (let i = MONTH_LOOP_START; i < getMonthLoopEnd(monthsLength); i++) {
+ const monthSection = getMonthSection(i);
+ const nextRemainingDistance = remainingScrollDistance - monthSection.height * maxScrollPercent;
// Check if we're in this month (with subpixel tolerance)
- if (next < -1 && monthData.monthGroup) {
- scrubberMonth = monthData.monthGroup;
+ if (nextRemainingDistance < SUBPIXEL_TOLERANCE && monthSection.monthGroup) {
+ viewportTopMonth = monthSection.monthGroup;
- // Calculate month percentage
- scrubberMonthPercent = Math.max(0, top / (monthData.height * maxScrollPercent));
+ // Calculate how far we've scrolled into this month as a percentage
+ viewportTopMonthScrollPercent = Math.max(0, remainingScrollDistance / (monthSection.height * maxScrollPercent));
// Handle rounding errors (and/or subpixel tolerance) -
// advance to next month if almost at end
- if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
- scrubberMonth = timelineManager.months[i + 1].yearMonth;
- scrubberMonthPercent = 0;
+ if (isNearMonthBoundary(viewportTopMonthScrollPercent) && canAdvanceToNextMonth(i, monthsLength)) {
+ viewportTopMonth = timelineManager.months[i + 1].yearMonth;
+ viewportTopMonthScrollPercent = 0;
}
- found = true;
+ foundIntersectingMonth = true;
break;
}
- top = next;
+ remainingScrollDistance = nextRemainingDistance;
}
- if (!found) {
- leadOut = true;
- scrubOverallPercent = 1;
+ if (!foundIntersectingMonth) {
+ isInLeadOutSection = true;
+ timelineScrollPercent = 1;
resetScrubberMonth();
}
};
- const getMonthData = (index: number) => {
+ const getMonthSection = (index: number): MonthSection => {
const monthsLength = timelineManager.months.length;
- if (index === -1) {
- // lead-in
+ if (index === MONTH_LOOP_START) {
return {
+ type: 'lead-in',
height: timelineManager.topSectionHeight,
monthGroup: undefined,
};
}
if (index === monthsLength) {
- // lead-out
return {
+ type: 'lead-out',
height: timelineManager.bottomSectionHeight,
monthGroup: undefined,
};
}
- // normal month
return {
+ type: 'month',
height: timelineManager.months[index].height,
monthGroup: timelineManager.months[index].yearMonth,
};
};
+ const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => {
+ const maxScroll = timelineManager.getMaxScroll();
+ const offset = maxScroll * percent;
+ scrollTo?.(offset);
+ };
+
+ const findMonthGroup = (target: TimelineYearMonth) => {
+ return timelineManager.months.find(
+ ({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month,
+ );
+ };
+
// 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, scrollToFunction } = scrubberData;
- if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
- // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
- const maxScroll = timelineManager.getMaxScroll();
- const offset = maxScroll * overallScrollPercent;
- scrollToFunction?.(offset);
+ // Handle edge case or no month selected
+ if (!scrubberMonth || isSmallTimeline()) {
+ handleOverallPercentScroll(overallScrollPercent, scrollToFunction);
return;
}
- const monthGroup = timelineManager.months.find(
- ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
- );
- if (!monthGroup) {
- return;
+
+ // Find and scroll to the selected month
+ const monthGroup = findMonthGroup(scrubberMonth);
+ if (monthGroup) {
+ scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
}
- scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
};
- const scrollToMonthGroupAndOffset = (
+ const scrollToPositionWithinMonth = (
monthGroup: MonthGroup,
monthGroupScrollPercent: number,
handleScrollTop?: (top: number) => void,
@@ -218,18 +256,18 @@
{empty}
{handleTimelineScroll}
>
- {#snippet header(scrollTo)}
+ {#snippet header(scrollToFunction)}
{#if timelineManager.months.length > 0}
onScrub({ ...args, scrollToFunction: scrollTo })}
+ {isInLeadOutSection}
+ {timelineScrollPercent}
+ {viewportTopMonthScrollPercent}
+ {viewportTopMonth}
+ onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })}
bind:scrubberWidth
/>
{/if}
diff --git a/web/src/lib/components/timeline/base-components/scrubber.svelte b/web/src/lib/components/timeline/base-components/scrubber.svelte
index 66b24d553a..4ee5fdab76 100644
--- a/web/src/lib/components/timeline/base-components/scrubber.svelte
+++ b/web/src/lib/components/timeline/base-components/scrubber.svelte
@@ -4,7 +4,7 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util';
- import { type ScrubberListener } from '$lib/utils/timeline-util';
+ import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
@@ -15,10 +15,10 @@
timelineBottomOffset?: number;
height?: number;
timelineManager: TimelineManager;
- scrubOverallPercent?: number;
- scrubberMonthPercent?: number;
- scrubberMonth?: { year: number; month: number };
- leadOut?: boolean;
+ timelineScrollPercent?: number;
+ viewportTopMonthScrollPercent?: number;
+ viewportTopMonth?: TimelineYearMonth;
+ isInLeadOutSection?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener;
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
@@ -31,10 +31,10 @@
timelineBottomOffset = 0,
height = 0,
timelineManager,
- scrubOverallPercent = 0,
- scrubberMonthPercent = 0,
- scrubberMonth = undefined,
- leadOut = false,
+ timelineScrollPercent = 0,
+ viewportTopMonthScrollPercent = 0,
+ viewportTopMonth = undefined,
+ isInLeadOutSection = false,
onScrub = undefined,
onScrubKeyDown = undefined,
startScrub = undefined,
@@ -100,7 +100,7 @@
offset += scrubberMonthPercent * relativeBottomOffset;
}
return offset;
- } else if (leadOut) {
+ } else if (isInLeadOutSection) {
let offset = relativeTopOffset;
for (const segment of segments) {
offset += segment.height;
@@ -111,7 +111,9 @@
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
}
};
- let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
+ let scrollY = $derived(
+ toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
+ );
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));