From 133495a67ba8e54d24edf0300b70d69448f29074 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 7 Oct 2025 09:43:27 -0400 Subject: [PATCH] refactor(web): Clarify property names in Timeline and Scrubber (#22265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(web): Clarify property names in Timeline and Scrubber Renamed properties across Timeline/Scrubber components for clarity: - scrubOverallPercent → timelineScrollPercent - scrubberMonthPercent → viewportTopMonthScrollPercent - scrubberMonth → viewportTopMonth - leadout → isInLeadOutSection Additional changes: - Updated ScrubberListener signature to accept object parameter - Added detailed JSDoc comments for all Scrubber props - Fixed callback invocations to use new object syntax - Aligned Timeline's local state variables with Scrubber prop names --- .../lib/components/timeline/Scrubber.svelte | 73 ++++++++++++++----- .../lib/components/timeline/Timeline.svelte | 68 +++++++++-------- web/src/lib/utils/timeline-util.ts | 10 +-- 3 files changed, 97 insertions(+), 54 deletions(-) diff --git a/web/src/lib/components/timeline/Scrubber.svelte b/web/src/lib/components/timeline/Scrubber.svelte index d6e5bb0833..1344e667dc 100644 --- a/web/src/lib/components/timeline/Scrubber.svelte +++ b/web/src/lib/components/timeline/Scrubber.svelte @@ -3,7 +3,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 { Icon } from '@immich/ui'; import { mdiPlay } from '@mdi/js'; import { clamp } from 'lodash-es'; @@ -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; - scrubOverallPercent?: number; - scrubberMonthPercent?: number; - scrubberMonth?: { year: number; month: number }; - leadout?: boolean; + /** 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; } @@ -31,10 +44,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 +113,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 +124,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)); @@ -295,12 +310,24 @@ const scrollPercent = toTimelineY(hoverY); if (wasDragging === false && isDragging) { - void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); - void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); + void startScrub?.({ + scrubberMonth: segmentDate!, + overallScrollPercent: scrollPercent, + scrubberMonthScrollPercent: monthGroupPercentY, + }); + void onScrub?.({ + scrubberMonth: segmentDate!, + overallScrollPercent: scrollPercent, + scrubberMonthScrollPercent: monthGroupPercentY, + }); } if (wasDragging && !isDragging) { - void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); + void stopScrub?.({ + scrubberMonth: segmentDate!, + overallScrollPercent: scrollPercent, + scrubberMonthScrollPercent: monthGroupPercentY, + }); return; } @@ -308,7 +335,11 @@ return; } - void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); + void onScrub?.({ + scrubberMonth: segmentDate!, + overallScrollPercent: scrollPercent, + scrubberMonthScrollPercent: monthGroupPercentY, + }); }; /* eslint-disable tscompat/tscompat */ const getTouch = (event: TouchEvent) => { @@ -412,7 +443,11 @@ } if (next) { event.preventDefault(); - void onScrub?.({ year: next.year, month: next.month }, -1, 0); + void onScrub?.({ + scrubberMonth: { year: next.year, month: next.month }, + overallScrollPercent: -1, + scrubberMonthScrollPercent: 0, + }); return true; } } @@ -422,7 +457,11 @@ const next = segments[idx + 1]; if (next) { event.preventDefault(); - void onScrub?.({ year: next.year, month: next.month }, -1, 0); + void onScrub?.({ + scrubberMonth: { year: next.year, month: next.month }, + overallScrollPercent: -1, + scrubberMonthScrollPercent: 0, + }); return true; } } diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 2849b50815..f609208378 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -110,14 +110,19 @@ let timelineElement: HTMLElement | undefined = $state(); let showSkeleton = $state(true); let isShowSelectDate = $state(false); - let scrubberMonthPercent = $state(0); - let scrubberMonth: { year: number; month: number } | undefined = $state(undefined); - let scrubOverallPercent: number = $state(0); + // 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: { year: number; month: number } | undefined = $state(undefined); + // Overall scroll percentage through the entire timeline (0-1) + let timelineScrollPercent: number = $state(0); let scrubberWidth = $state(0); // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; - let leadout = $state(false); + // Indicates whether the viewport is currently in the lead-out section (after all months) + let isInLeadOutSection = $state(false); const maxMd = $derived(mobileDevice.maxMd); const usingMobileDevice = $derived(mobileDevice.pointerCoarse); @@ -301,20 +306,19 @@ scrollTop(scrollToTop); }; - // note: don't throttle, debounch, or otherwise make this function async - it causes flicker - const onScrub: ScrubberListener = ( - scrubMonth: { year: number; month: number }, - overallScrollPercent: number, - scrubberMonthScrollPercent: number, - ) => { - if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { + // 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; + + 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 = getMaxScroll(); const offset = maxScroll * overallScrollPercent; scrollTop(offset); } else { const monthGroup = timelineManager.months.find( - ({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month, + ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ); if (!monthGroup) { return; @@ -325,7 +329,7 @@ // note: don't throttle, debounch, or otherwise make this function async - it causes flicker const handleTimelineScroll = () => { - leadout = false; + isInLeadOutSection = false; if (!element) { return; @@ -334,19 +338,19 @@ if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { // edge case - scroll limited due to size of content, must adjust - use the overall percent instead const maxScroll = getMaxScroll(); - scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); + timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); - scrubberMonth = undefined; - scrubberMonthPercent = 0; + viewportTopMonth = undefined; + viewportTopMonthScrollPercent = 0; } else { let top = element.scrollTop; if (top < timelineManager.topSectionHeight) { // in the lead-in area - scrubberMonth = undefined; - scrubberMonthPercent = 0; + viewportTopMonth = undefined; + viewportTopMonthScrollPercent = 0; const maxScroll = getMaxScroll(); - scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); + timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); return; } @@ -371,15 +375,15 @@ let next = top - monthGroupHeight * maxScrollPercent; // instead of checking for < 0, add a little wiggle room for subpixel resolution if (next < -1 && monthGroup) { - scrubberMonth = monthGroup; + viewportTopMonth = monthGroup; // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage - scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent)); + viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent)); // compensate for lost precision/rounding errors advance to the next bucket, if present - if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) { - scrubberMonth = timelineManager.months[i + 1].yearMonth; - scrubberMonthPercent = 0; + if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) { + viewportTopMonth = timelineManager.months[i + 1].yearMonth; + viewportTopMonthScrollPercent = 0; } found = true; @@ -388,10 +392,10 @@ top = next; } if (!found) { - leadout = true; - scrubberMonth = undefined; - scrubberMonthPercent = 0; - scrubOverallPercent = 1; + isInLeadOutSection = true; + viewportTopMonth = undefined; + viewportTopMonthScrollPercent = 0; + timelineScrollPercent = 1; } } }; @@ -854,10 +858,10 @@ height={timelineManager.viewportHeight} timelineTopOffset={timelineManager.topSectionHeight} timelineBottomOffset={bottomSectionHeight} - {leadout} - {scrubOverallPercent} - {scrubberMonthPercent} - {scrubberMonth} + {isInLeadOutSection} + {timelineScrollPercent} + {viewportTopMonthScrollPercent} + {viewportTopMonth} {onScrub} bind:scrubberWidth onScrubKeyDown={(evt) => { diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 9cf4428da6..80cac0b738 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -23,11 +23,11 @@ export type TimelineDateTime = TimelineDate & { millisecond: number; }; -export type ScrubberListener = ( - scrubberMonth: { year: number; month: number }, - overallScrollPercent: number, - scrubberMonthScrollPercent: number, -) => void | Promise; +export type ScrubberListener = (scrubberData: { + scrubberMonth: { year: number; month: number }; + overallScrollPercent: number; + scrubberMonthScrollPercent: number; +}) => void | Promise; // used for AssetResponseDto.dateTimeOriginal, amongst others export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime =>