diff --git a/web/src/lib/components/timeline/base-components/base-timeline.svelte b/web/src/lib/components/timeline/base-components/base-timeline.svelte index 7fa1464ae2..3103fa4c3b 100644 --- a/web/src/lib/components/timeline/base-components/base-timeline.svelte +++ b/web/src/lib/components/timeline/base-components/base-timeline.svelte @@ -53,12 +53,16 @@ const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small" - 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); + // The timeline month intersecting the top position of the viewport let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined); + // Overall scroll percentage through the entire timeline (0-1) let timelineScrollPercent: number = $state(0); + // Indicates whether the viewport is currently in the lead-out section (after all months) + let isInLeadOutSection = $state(false); + // Width of the scrubber component in pixels, used to adjust timeline margins let scrubberWidth: number = $state(0); // note: don't throttle, debounce, or otherwise make this function async - it causes flicker @@ -66,16 +70,10 @@ const handleTimelineScroll = () => { isInLeadOutSection = false; - // Handle small timeline edge case: scroll limited due to size of content - if (isSmallTimeline()) { - handleSmallTimelineScroll(); - return; - } - - // Handle scrolling of the lead-in area + // Handle edge cases: small timeline (limited scroll) or lead-in area scrolling const top = timelineManager.visibleWindow.top; - if (top < timelineManager.topSectionHeight) { - handleLeadInScroll(); + if (isSmallTimeline() || top < timelineManager.topSectionHeight) { + calculateTimelineScrollPercent(); return; } @@ -83,29 +81,6 @@ handleMonthScroll(); }; - const isSmallTimeline = () => { - return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER; - }; - - const resetScrubberMonth = () => { - viewportTopMonth = undefined; - viewportTopMonthScrollPercent = 0; - }; - - const calculateTimelineScrollPercent = () => { - const maxScroll = timelineManager.getMaxScroll(); - timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll); - resetScrubberMonth(); - }; - - const handleSmallTimelineScroll = () => { - calculateTimelineScrollPercent(); - }; - - const handleLeadInScroll = () => { - calculateTimelineScrollPercent(); - }; - const handleMonthScroll = () => { const scrollPosition = timelineManager.visibleWindow.top; const months = timelineManager.months; @@ -118,12 +93,24 @@ viewportTopMonth = searchResult.month; viewportTopMonthScrollPercent = searchResult.monthScrollPercent; isInLeadOutSection = false; - } else { - // We're in lead-out section - isInLeadOutSection = true; - timelineScrollPercent = 1; - resetScrubberMonth(); + return; } + + // We're in lead-out section + isInLeadOutSection = true; + timelineScrollPercent = 1; + resetScrubberMonth(); + }; + + const resetScrubberMonth = () => { + viewportTopMonth = undefined; + viewportTopMonthScrollPercent = 0; + }; + + const calculateTimelineScrollPercent = () => { + const maxScroll = timelineManager.getMaxScroll(); + timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll); + resetScrubberMonth(); }; const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => { @@ -138,6 +125,10 @@ ); }; + const isSmallTimeline = () => { + return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER; + }; + // 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) => { diff --git a/web/src/lib/components/timeline/base-components/scrubber.svelte b/web/src/lib/components/timeline/base-components/scrubber.svelte index 4ee5fdab76..b1f8f5eb03 100644 --- a/web/src/lib/components/timeline/base-components/scrubber.svelte +++ b/web/src/lib/components/timeline/base-components/scrubber.svelte @@ -11,18 +11,31 @@ import { fade, fly } from 'svelte/transition'; interface Props { + /** Offset from the top of the timeline (e.g., for headers) */ timelineTopOffset?: number; + /** Offset from the bottom of the timeline (e.g., for footers) */ timelineBottomOffset?: number; + /** Total height of the scrubber component */ height?: number; + /** Timeline manager instance that controls the timeline state */ timelineManager: TimelineManager; + /** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */ timelineScrollPercent?: number; + /** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */ viewportTopMonthScrollPercent?: number; + /** The year/month of the timeline month at the top of the viewport */ viewportTopMonth?: TimelineYearMonth; + /** Indicates whether the viewport is currently in the lead-out section (after all months) */ isInLeadOutSection?: boolean; + /** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */ scrubberWidth?: number; + /** Callback fired when user interacts with the scrubber to navigate */ onScrub?: ScrubberListener; + /** Callback fired when keyboard events occur on the scrubber */ onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; + /** Callback fired when scrubbing starts */ startScrub?: ScrubberListener; + /** Callback fired when scrubbing stops */ stopScrub?: ScrubberListener; } diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index c35b02b70e..6eec79b414 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -274,7 +274,7 @@ export function findMonthAtScrollPosition( } // Check if we're after the last month - const lastMonth = months[months.length - 1]; + const lastMonth = months.at(-1)!; const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent; if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) { return null; @@ -293,7 +293,7 @@ export function findMonthAtScrollPosition( if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) { // Found the month containing the scroll position const distanceIntoMonth = scrollPosition - monthTop; - let monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); + const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); // Handle month boundary edge case if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {