From 6c9d506c74b2f4279e2023922b219abb3c3072e9 Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 15 Sep 2025 13:37:41 +0000 Subject: [PATCH] refactor: new timeline component - Create new timeline component (extracted from asset-gird) without removing old code - Add timeline/, timeline/actions/, timeline/base-components/, and timeline/internal-components/ directories - Copy needed components (delete-asset-dialog, scrubber, skeleton) to new locations - Add new timeline components (base-timeline, base-timeline-viewer, timeline-month, etc.) - Update timeline-util.ts with new functions (findMonthAtScrollPosition, formatGroupTitleFull) - Add asset-viewer-actions and asset-viewer-and-actions components This allows the timeline to exist alongside the current AssetGrid component. --- i18n/en.json | 2 + .../photos-page/asset-viewer-actions.svelte | 162 +++++ .../asset-viewer-and-actions.svelte | 73 +++ .../scrubber/scrubber.svelte | 73 ++- .../actions/delete-asset-dialog.svelte | 47 ++ .../actions/timeline-keyboard-actions.svelte | 223 +++++++ .../base-timeline-viewer.svelte | 318 ++++++++++ .../base-components/base-timeline.svelte | 201 ++++++ .../timeline/base-components/hmr.svelte | 18 + .../timeline/base-components/scrubber.svelte | 593 ++++++++++++++++++ .../timeline/base-components/skeleton.svelte | 44 ++ .../base-components/timeline-month.svelte | 181 ++++++ .../selectable-timeline-month.svelte | 276 ++++++++ .../timeline-asset-viewer.svelte | 177 ++++++ .../lib/components/timeline/timeline.svelte | 102 +++ .../timeline-manager/day-group.svelte.ts | 4 +- .../internal/search-support.svelte.ts | 21 + .../timeline-manager/month-group.svelte.ts | 4 +- .../timeline-manager.svelte.ts | 10 + web/src/lib/utils/timeline-util.ts | 95 ++- 20 files changed, 2600 insertions(+), 24 deletions(-) create mode 100644 web/src/lib/components/photos-page/asset-viewer-actions.svelte create mode 100644 web/src/lib/components/photos-page/asset-viewer-and-actions.svelte create mode 100644 web/src/lib/components/timeline/actions/delete-asset-dialog.svelte create mode 100644 web/src/lib/components/timeline/actions/timeline-keyboard-actions.svelte create mode 100644 web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte create mode 100644 web/src/lib/components/timeline/base-components/base-timeline.svelte create mode 100644 web/src/lib/components/timeline/base-components/hmr.svelte create mode 100644 web/src/lib/components/timeline/base-components/scrubber.svelte create mode 100644 web/src/lib/components/timeline/base-components/skeleton.svelte create mode 100644 web/src/lib/components/timeline/base-components/timeline-month.svelte create mode 100644 web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte create mode 100644 web/src/lib/components/timeline/internal-components/timeline-asset-viewer.svelte create mode 100644 web/src/lib/components/timeline/timeline.svelte diff --git a/i18n/en.json b/i18n/en.json index fe38b44998..43c46a0c40 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1337,6 +1337,8 @@ "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", + "navigate": "Navigate", + "navigate_to_time": "Navigate to Time", "network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirements_updated": "Network requirements changed, resetting backup queue", diff --git a/web/src/lib/components/photos-page/asset-viewer-actions.svelte b/web/src/lib/components/photos-page/asset-viewer-actions.svelte new file mode 100644 index 0000000000..bdf8ccba85 --- /dev/null +++ b/web/src/lib/components/photos-page/asset-viewer-actions.svelte @@ -0,0 +1,162 @@ + diff --git a/web/src/lib/components/photos-page/asset-viewer-and-actions.svelte b/web/src/lib/components/photos-page/asset-viewer-and-actions.svelte new file mode 100644 index 0000000000..da69341857 --- /dev/null +++ b/web/src/lib/components/photos-page/asset-viewer-and-actions.svelte @@ -0,0 +1,73 @@ + + + + +{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + +{/await} diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index 4e49f9a012..b1f8f5eb03 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -4,25 +4,38 @@ 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'; 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/actions/delete-asset-dialog.svelte b/web/src/lib/components/timeline/actions/delete-asset-dialog.svelte new file mode 100644 index 0000000000..c04b5f39d0 --- /dev/null +++ b/web/src/lib/components/timeline/actions/delete-asset-dialog.svelte @@ -0,0 +1,47 @@ + + + (confirmed ? handleConfirm() : onCancel())} +> + {#snippet promptSnippet()} +

+ + {#snippet children({ message })} + {message} + {/snippet} + +

+

{$t('cannot_undo_this_action')}

+ +
+ +
+ {/snippet} +
diff --git a/web/src/lib/components/timeline/actions/timeline-keyboard-actions.svelte b/web/src/lib/components/timeline/actions/timeline-keyboard-actions.svelte new file mode 100644 index 0000000000..ef52072a98 --- /dev/null +++ b/web/src/lib/components/timeline/actions/timeline-keyboard-actions.svelte @@ -0,0 +1,223 @@ + + + + +{#if isShowDeleteConfirmation} + (isShowDeleteConfirmation = false)} + onConfirm={() => handlePromiseError(trashOrDelete(true))} + /> +{/if} + +{#if isShowSelectDate} + { + isShowSelectDate = false; + if (result.mode === 'absolute') { + const asset = await timelineManager.getClosestAssetToDate( + (DateTime.fromISO(result.date) as DateTime).toObject(), + ); + if (asset) { + setFocusAsset(asset); + } + } + }} + onCancel={() => (isShowSelectDate = false)} + /> +{/if} 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 new file mode 100644 index 0000000000..32bdb4e0ac --- /dev/null +++ b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte @@ -0,0 +1,318 @@ + + + { + // 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('base-timeline-viewer.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.viewId)} + {@const display = monthGroup.intersecting} + {@const absoluteHeight = monthGroup.top} + + {#if !monthGroup.isLoaded} +
+ +
+ {:else if display} +
+ { + if (isSingleSelect) { + scrollTo(0); + } + onSelect?.(asset); + }} + onScrollCompensationMonthInDOM={scrollCompensation} + /> +
+ {/if} + {/each} + +
+
+
+ + diff --git a/web/src/lib/components/timeline/base-components/base-timeline.svelte b/web/src/lib/components/timeline/base-components/base-timeline.svelte new file mode 100644 index 0000000000..69dec157b4 --- /dev/null +++ b/web/src/lib/components/timeline/base-components/base-timeline.svelte @@ -0,0 +1,201 @@ + + + + {#snippet header(scrollToFunction)} + {#if timelineManager.months.length > 0} + onScrub({ ...scrubberData, scrollToFunction })} + bind:scrubberWidth + /> + {/if} + {/snippet} + diff --git a/web/src/lib/components/timeline/base-components/hmr.svelte b/web/src/lib/components/timeline/base-components/hmr.svelte new file mode 100644 index 0000000000..f46d1667df --- /dev/null +++ b/web/src/lib/components/timeline/base-components/hmr.svelte @@ -0,0 +1,18 @@ + diff --git a/web/src/lib/components/timeline/base-components/scrubber.svelte b/web/src/lib/components/timeline/base-components/scrubber.svelte new file mode 100644 index 0000000000..b1f8f5eb03 --- /dev/null +++ b/web/src/lib/components/timeline/base-components/scrubber.svelte @@ -0,0 +1,593 @@ + + + (isDragging || isHover) && handleMouseEvent({ clientY })} + onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} +/> + +
(isHover = true)} + onmouseleave={() => (isHover = false)} + onkeydown={keydown} + draggable="false" +> + {#if !usingMobileDevice && hoverLabel && (isHover || isDragging)} +
+ {hoverLabel} +
+ {/if} + {#if usingMobileDevice && ((timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging)} +
+ + + {#if (timelineManager.scrolling && scrollHoverLabel) || isHover || isDragging} +

+ {scrollHoverLabel} +

+ {/if} +
+ {/if} + + {#if !usingMobileDevice && !isDragging} +
+ {#if timelineManager.scrolling && scrollHoverLabel && !isHover} +

+ {scrollHoverLabel} +

+ {/if} +
+ {/if} +
+ {#if relativeTopOffset > 6} +
+ {/if} +
+ + {#each segments as segment (segment.year + '-' + segment.month)} +
+ {#if !usingMobileDevice} + {#if segment.hasLabel} +
+ {segment.year} +
+ {/if} + {#if segment.hasDot} +
+ {/if} + {/if} +
+ {/each} +
+
diff --git a/web/src/lib/components/timeline/base-components/skeleton.svelte b/web/src/lib/components/timeline/base-components/skeleton.svelte new file mode 100644 index 0000000000..4ede4e0024 --- /dev/null +++ b/web/src/lib/components/timeline/base-components/skeleton.svelte @@ -0,0 +1,44 @@ + + +
+
+ {title} +
+
+
+ + diff --git a/web/src/lib/components/timeline/base-components/timeline-month.svelte b/web/src/lib/components/timeline/base-components/timeline-month.svelte new file mode 100644 index 0000000000..bc3c13afb1 --- /dev/null +++ b/web/src/lib/components/timeline/base-components/timeline-month.svelte @@ -0,0 +1,181 @@ + + +{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} + {@const absoluteWidth = dayGroup.left} + + +
{ + isMouseOverGroup = true; + hoveredDayGroup = dayGroup.groupTitle; + }} + onmouseleave={() => { + isMouseOverGroup = false; + hoveredDayGroup = null; + }} + > + +
+ {#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected(dayGroup))} +
onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} + onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} + > + {#if isDayGroupSelected(dayGroup)} + + {:else} + + {/if} +
+ {/if} + + + {dayGroup.groupTitle} + +
+ + +
+ {#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)} + {@const position = viewerAsset.position!} + {@const asset = viewerAsset.asset!} + + +
+ onAssetOpen?.(dayGroup, assetSnapshot(asset))} + onSelect={() => onAssetSelect(dayGroup, assetSnapshot(asset))} + onMouseEvent={() => onHover(dayGroup, assetSnapshot(asset))} + selected={isAssetSelected(asset)} + selectionCandidate={isAssetSelectionCandidate(asset)} + disabled={isAssetDisabled(asset)} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {#if customThumbnailLayout} + {@render customThumbnailLayout(asset)} + {/if} +
+ {/each} +
+
+{/each} + + diff --git a/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte b/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte new file mode 100644 index 0000000000..b097bda668 --- /dev/null +++ b/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte @@ -0,0 +1,276 @@ + + + + + assetInteraction.selectedGroup.has(dayGroup.groupTitle)} + isAssetSelected={(asset) => assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} + isAssetSelectionCandidate={(asset) => assetInteraction.hasSelectionCandidate(asset.id)} + isAssetDisabled={(asset) => timelineManager.albumAssets.has(asset.id)} +/> diff --git a/web/src/lib/components/timeline/internal-components/timeline-asset-viewer.svelte b/web/src/lib/components/timeline/internal-components/timeline-asset-viewer.svelte new file mode 100644 index 0000000000..ce7dac11d1 --- /dev/null +++ b/web/src/lib/components/timeline/internal-components/timeline-asset-viewer.svelte @@ -0,0 +1,177 @@ + + +{#await import('../../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + +{/await} diff --git a/web/src/lib/components/timeline/timeline.svelte b/web/src/lib/components/timeline/timeline.svelte new file mode 100644 index 0000000000..24c22fd513 --- /dev/null +++ b/web/src/lib/components/timeline/timeline.svelte @@ -0,0 +1,102 @@ + + + + + viewer?.scrollToAsset(asset) ?? false} + {timelineManager} + {assetInteraction} + bind:isShowDeleteConfirmation + {onEscape} +/> + + + {#if $showAssetViewer} + + {/if} + diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index 9d5008bf83..384bc15af3 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -13,6 +13,7 @@ export class DayGroup { readonly monthGroup: MonthGroup; readonly index: number; readonly groupTitle: string; + readonly groupTitleFull: string; readonly day: number; viewerAssets: ViewerAsset[] = $state([]); @@ -26,11 +27,12 @@ export class DayGroup { #col = $state(0); #deferredLayout = false; - constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) { + constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) { this.index = index; this.monthGroup = monthGroup; this.day = day; this.groupTitle = groupTitle; + this.groupTitleFull = groupTitleFull; } get top() { 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..f1203016af 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,24 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe } } } + +export function findClosestGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { + let closestMonth: MonthGroup | undefined; + let minDifference = Number.MAX_SAFE_INTEGER; + + for (const month of timelineManager.months) { + const { year, month: monthNum } = month.yearMonth; + + // Calculate the absolute difference in months + const yearDiff = Math.abs(year - targetYearMonth.year); + const monthDiff = Math.abs(monthNum - targetYearMonth.month); + const totalDiff = yearDiff * 12 + monthDiff; + + if (totalDiff < minDifference) { + minDifference = totalDiff; + closestMonth = month; + } + } + + return closestMonth; +} diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index e406972900..52a76f2c35 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -4,6 +4,7 @@ import { CancellableTask } from '$lib/utils/cancellable-task'; import { handleError } from '$lib/utils/handle-error'; import { formatGroupTitle, + formatGroupTitleFull, formatMonthGroupTitle, fromTimelinePlainDate, fromTimelinePlainDateTime, @@ -222,7 +223,8 @@ export class MonthGroup { addContext.setDayGroup(dayGroup, localDateTime); } else { const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); - dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle); + const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime)); + dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull); this.dayGroups.push(dayGroup); addContext.setDayGroup(dayGroup, localDateTime); addContext.newDayGroups.add(dayGroup); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 172cd07a02..132a05805e 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -41,6 +41,7 @@ export class TimelineManager { isInitialized = $state(false); months: MonthGroup[] = $state([]); topSectionHeight = $state(0); + bottomSectionHeight = $state(60); timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight); assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); @@ -552,4 +553,13 @@ export class TimelineManager { getAssetOrder() { return this.#options.order ?? AssetOrder.Desc; } + + getMaxScrollPercent() { + const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight; + return (totalHeight - this.viewportHeight) / totalHeight; + } + + getMaxScroll() { + return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight); + } } diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 9cf4428da6..6eec79b414 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -23,11 +23,12 @@ 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; + scrollToFunction?: (top: number) => void; +}) => void | Promise; // used for AssetResponseDto.dateTimeOriginal, amongst others export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime => @@ -151,6 +152,14 @@ export function formatGroupTitle(_date: DateTime): string { return getDateLocaleString(date, { locale: get(locale) }); } +export const formatGroupTitleFull = (_date: DateTime): string => { + if (!_date.isValid) { + return _date.toString(); + } + const date = _date as DateTime; + return getDateLocaleString(date); +}; + export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); @@ -234,3 +243,79 @@ export function setDifference(setA: Set, setB: Set): SvelteSet { } return result; } + +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; +}