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
This commit is contained in:
midzelis 2025-09-21 14:54:15 +00:00
parent bea116e1b9
commit 9051fa6949
3 changed files with 97 additions and 54 deletions

View File

@ -3,7 +3,7 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types'; import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; 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 { Icon } from '@immich/ui';
import { mdiPlay } from '@mdi/js'; import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@ -11,18 +11,31 @@
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number; timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
timelineBottomOffset?: number; timelineBottomOffset?: number;
/** Total height of the scrubber component */
height?: number; height?: number;
/** Timeline manager instance that controls the timeline state */
timelineManager: TimelineManager; timelineManager: TimelineManager;
scrubOverallPercent?: number; /** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
scrubberMonthPercent?: number; timelineScrollPercent?: number;
scrubberMonth?: { year: number; month: number }; /** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
leadout?: boolean; 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; scrubberWidth?: number;
/** Callback fired when user interacts with the scrubber to navigate */
onScrub?: ScrubberListener; onScrub?: ScrubberListener;
/** Callback fired when keyboard events occur on the scrubber */
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
/** Callback fired when scrubbing starts */
startScrub?: ScrubberListener; startScrub?: ScrubberListener;
/** Callback fired when scrubbing stops */
stopScrub?: ScrubberListener; stopScrub?: ScrubberListener;
} }
@ -31,10 +44,10 @@
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
timelineManager, timelineManager,
scrubOverallPercent = 0, timelineScrollPercent = 0,
scrubberMonthPercent = 0, viewportTopMonthScrollPercent = 0,
scrubberMonth = undefined, viewportTopMonth = undefined,
leadout = false, isInLeadOutSection = false,
onScrub = undefined, onScrub = undefined,
onScrubKeyDown = undefined, onScrubKeyDown = undefined,
startScrub = undefined, startScrub = undefined,
@ -100,7 +113,7 @@
offset += scrubberMonthPercent * relativeBottomOffset; offset += scrubberMonthPercent * relativeBottomOffset;
} }
return offset; return offset;
} else if (leadout) { } else if (isInLeadOutSection) {
let offset = relativeTopOffset; let offset = relativeTopOffset;
for (const segment of segments) { for (const segment of segments) {
offset += segment.height; offset += segment.height;
@ -111,7 +124,9 @@
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)); 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 timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
@ -295,12 +310,24 @@
const scrollPercent = toTimelineY(hoverY); const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) { if (wasDragging === false && isDragging) {
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void startScrub?.({
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
} }
if (wasDragging && !isDragging) { if (wasDragging && !isDragging) {
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void stopScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
return; return;
} }
@ -308,7 +335,11 @@
return; return;
} }
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
}; };
/* eslint-disable tscompat/tscompat */ /* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => { const getTouch = (event: TouchEvent) => {
@ -412,7 +443,11 @@
} }
if (next) { if (next) {
event.preventDefault(); 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; return true;
} }
} }
@ -422,7 +457,11 @@
const next = segments[idx + 1]; const next = segments[idx + 1];
if (next) { if (next) {
event.preventDefault(); 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; return true;
} }
} }

View File

@ -110,14 +110,19 @@
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true); let showSkeleton = $state(true);
let isShowSelectDate = $state(false); let isShowSelectDate = $state(false);
let scrubberMonthPercent = $state(0); // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined); // Note: There may be multiple months visible within the viewport at any given time.
let scrubOverallPercent: number = $state(0); 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); let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px // 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60; 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 maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
@ -301,20 +306,19 @@
scrollTop(scrollToTop); scrollTop(scrollToTop);
}; };
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker // note: don't throttle, debounce, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = ( // this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
scrubMonth: { year: number; month: number }, const onScrub: ScrubberListener = (scrubberData) => {
overallScrollPercent: number, const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
scrubberMonthScrollPercent: number,
) => { if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent; const offset = maxScroll * overallScrollPercent;
scrollTop(offset); scrollTop(offset);
} else { } else {
const monthGroup = timelineManager.months.find( 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) { if (!monthGroup) {
return; return;
@ -325,7 +329,7 @@
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => { const handleTimelineScroll = () => {
leadout = false; isInLeadOutSection = false;
if (!element) { if (!element) {
return; return;
@ -334,19 +338,19 @@
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
} else { } else {
let top = element.scrollTop; let top = element.scrollTop;
if (top < timelineManager.topSectionHeight) { if (top < timelineManager.topSectionHeight) {
// in the lead-in area // in the lead-in area
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
return; return;
} }
@ -371,15 +375,15 @@
let next = top - monthGroupHeight * maxScrollPercent; let next = top - monthGroupHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution // instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && monthGroup) { 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 // 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 // compensate for lost precision/rounding errors advance to the next bucket, if present
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) { if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
scrubberMonth = timelineManager.months[i + 1].yearMonth; viewportTopMonth = timelineManager.months[i + 1].yearMonth;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
} }
found = true; found = true;
@ -388,10 +392,10 @@
top = next; top = next;
} }
if (!found) { if (!found) {
leadout = true; isInLeadOutSection = true;
scrubberMonth = undefined; viewportTopMonth = undefined;
scrubberMonthPercent = 0; viewportTopMonthScrollPercent = 0;
scrubOverallPercent = 1; timelineScrollPercent = 1;
} }
} }
}; };
@ -854,10 +858,10 @@
height={timelineManager.viewportHeight} height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight} timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={bottomSectionHeight} timelineBottomOffset={bottomSectionHeight}
{leadout} {isInLeadOutSection}
{scrubOverallPercent} {timelineScrollPercent}
{scrubberMonthPercent} {viewportTopMonthScrollPercent}
{scrubberMonth} {viewportTopMonth}
{onScrub} {onScrub}
bind:scrubberWidth bind:scrubberWidth
onScrubKeyDown={(evt) => { onScrubKeyDown={(evt) => {

View File

@ -23,11 +23,11 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number; millisecond: number;
}; };
export type ScrubberListener = ( export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number }, scrubberMonth: { year: number; month: number };
overallScrollPercent: number, overallScrollPercent: number;
scrubberMonthScrollPercent: number, scrubberMonthScrollPercent: number;
) => void | Promise<void>; }) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others // used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> => export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>