mirror of
https://github.com/immich-app/immich.git
synced 2025-11-02 10:37:11 -05:00
fix(web): improve scrubber behavior on scroll-limited timelines (#22917)
Improves scroll indicator positioning when scrubbing through timelines with limited scrollable content (e.g., small albums). When a timeline's scrollable height is less than 50% of the viewport height, the scroll position is now properly distributed across the entire scrubber height, making the indicator more responsive and accurate. Changes: - Add `limitedScroll` state to detect scroll-constrained timelines (threshold: 50%) - Introduce `ViewportTopMonth` type to handle lead-in/lead-out sections - Calculate `totalViewerHeight` including top/bottom sections for accurate positioning - Refactor scrubber to treat lead-in and lead-out as distinct scroll segments - Update scroll position calculations to use relative percentages on constrained timelines
This commit is contained in:
parent
9b5855f848
commit
f1e03d0022
@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
import type { ScrubberMonth, ViewportTopMonth } 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, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } 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';
|
||||||
@ -24,9 +24,8 @@
|
|||||||
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
|
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
|
||||||
viewportTopMonthScrollPercent?: number;
|
viewportTopMonthScrollPercent?: number;
|
||||||
/** The year/month of the timeline month at the top of the viewport */
|
/** The year/month of the timeline month at the top of the viewport */
|
||||||
viewportTopMonth?: TimelineYearMonth;
|
viewportTopMonth?: ViewportTopMonth;
|
||||||
/** 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) */
|
/** 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 */
|
/** Callback fired when user interacts with the scrubber to navigate */
|
||||||
@ -47,7 +46,6 @@
|
|||||||
timelineScrollPercent = 0,
|
timelineScrollPercent = 0,
|
||||||
viewportTopMonthScrollPercent = 0,
|
viewportTopMonthScrollPercent = 0,
|
||||||
viewportTopMonth = undefined,
|
viewportTopMonth = undefined,
|
||||||
isInLeadOutSection = false,
|
|
||||||
onScrub = undefined,
|
onScrub = undefined,
|
||||||
onScrubKeyDown = undefined,
|
onScrubKeyDown = undefined,
|
||||||
startScrub = undefined,
|
startScrub = undefined,
|
||||||
@ -94,11 +92,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toScrollFromMonthGroupPercentage = (
|
const toScrollFromMonthGroupPercentage = (
|
||||||
scrubberMonth: { year: number; month: number } | undefined,
|
scrubberMonth: ViewportTopMonth,
|
||||||
scrubberMonthPercent: number,
|
scrubberMonthPercent: number,
|
||||||
scrubOverallPercent: number,
|
scrubOverallPercent: number,
|
||||||
) => {
|
) => {
|
||||||
if (scrubberMonth) {
|
if (scrubberMonth === 'lead-in') {
|
||||||
|
return relativeTopOffset * scrubberMonthPercent;
|
||||||
|
} else if (scrubberMonth === 'lead-out') {
|
||||||
|
let offset = relativeTopOffset;
|
||||||
|
for (const segment of segments) {
|
||||||
|
offset += segment.height;
|
||||||
|
}
|
||||||
|
return offset + relativeBottomOffset * scrubberMonthPercent;
|
||||||
|
} else if (scrubberMonth) {
|
||||||
let offset = relativeTopOffset;
|
let offset = relativeTopOffset;
|
||||||
let match = false;
|
let match = false;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
@ -113,23 +119,16 @@
|
|||||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
} else if (isInLeadOutSection) {
|
|
||||||
let offset = relativeTopOffset;
|
|
||||||
for (const segment of segments) {
|
|
||||||
offset += segment.height;
|
|
||||||
}
|
|
||||||
offset += scrubOverallPercent * relativeBottomOffset;
|
|
||||||
return offset;
|
|
||||||
} else {
|
} else {
|
||||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(
|
const scrollY = $derived(
|
||||||
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||||
);
|
);
|
||||||
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
|
||||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||||
|
|
||||||
type Segment = {
|
type Segment = {
|
||||||
count: number;
|
count: number;
|
||||||
@ -173,14 +172,13 @@
|
|||||||
segment.hasLabel = true;
|
segment.hasLabel = true;
|
||||||
previousLabeledSegment = segment;
|
previousLabeledSegment = segment;
|
||||||
}
|
}
|
||||||
if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
if (segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
||||||
segment.hasDot = true;
|
segment.hasDot = true;
|
||||||
dotHeight = 0;
|
dotHeight = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
height += segment.height;
|
height += segment.height;
|
||||||
dotHeight += segment.height;
|
|
||||||
}
|
}
|
||||||
|
dotHeight += segment.height;
|
||||||
segments.push(segment);
|
segments.push(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,7 +195,13 @@
|
|||||||
}
|
}
|
||||||
return activeSegment?.dataset.label;
|
return activeSegment?.dataset.label;
|
||||||
});
|
});
|
||||||
const segmentDate = $derived.by(() => {
|
const segmentDate: ViewportTopMonth = $derived.by(() => {
|
||||||
|
if (activeSegment?.dataset.id === 'lead-in') {
|
||||||
|
return 'lead-in';
|
||||||
|
}
|
||||||
|
if (activeSegment?.dataset.id === 'lead-out') {
|
||||||
|
return 'lead-out';
|
||||||
|
}
|
||||||
if (!activeSegment?.dataset.segmentYearMonth) {
|
if (!activeSegment?.dataset.segmentYearMonth) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -215,7 +219,22 @@
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const scrollHoverLabel = $derived(scrollSegment?.dateFormatted || '');
|
const scrollHoverLabel = $derived.by(() => {
|
||||||
|
if (scrollY !== undefined) {
|
||||||
|
if (scrollY < relativeTopOffset) {
|
||||||
|
return segments.at(0)?.dateFormatted;
|
||||||
|
} else {
|
||||||
|
let offset = relativeTopOffset;
|
||||||
|
for (const segment of segments) {
|
||||||
|
offset += segment.height;
|
||||||
|
}
|
||||||
|
if (scrollY > offset) {
|
||||||
|
return segments.at(-1)?.dateFormatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrollSegment?.dateFormatted || '';
|
||||||
|
});
|
||||||
|
|
||||||
const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
|
const findElementBestY = (elements: Element[], y: number, ...ids: string[]) => {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@ -308,38 +327,23 @@
|
|||||||
isHoverOnPaddingTop = isOnPaddingTop;
|
isHoverOnPaddingTop = isOnPaddingTop;
|
||||||
isHoverOnPaddingBottom = isOnPaddingBottom;
|
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||||
|
|
||||||
const scrollPercent = toTimelineY(hoverY);
|
const scrubData = {
|
||||||
|
scrubberMonth: segmentDate,
|
||||||
|
overallScrollPercent: toTimelineY(hoverY),
|
||||||
|
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||||
|
};
|
||||||
if (wasDragging === false && isDragging) {
|
if (wasDragging === false && isDragging) {
|
||||||
void startScrub?.({
|
void startScrub?.(scrubData);
|
||||||
scrubberMonth: segmentDate!,
|
void onScrub?.(scrubData);
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
void onScrub?.({
|
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasDragging && !isDragging) {
|
if (wasDragging && !isDragging) {
|
||||||
void stopScrub?.({
|
void stopScrub?.(scrubData);
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
void onScrub?.(scrubData);
|
||||||
void onScrub?.({
|
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const getTouch = (event: TouchEvent) => {
|
const getTouch = (event: TouchEvent) => {
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
@ -559,13 +563,8 @@
|
|||||||
class="relative"
|
class="relative"
|
||||||
style:height={relativeTopOffset + 'px'}
|
style:height={relativeTopOffset + 'px'}
|
||||||
data-id="lead-in"
|
data-id="lead-in"
|
||||||
data-segment-year-month={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
|
||||||
data-label={segments.at(0)?.dateFormatted}
|
data-label={segments.at(0)?.dateFormatted}
|
||||||
>
|
></div>
|
||||||
{#if relativeTopOffset > 6}
|
|
||||||
<div class="absolute end-3 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- Time Segment -->
|
<!-- Time Segment -->
|
||||||
{#each segments as segment (segment.year + '-' + segment.month)}
|
{#each segments as segment (segment.year + '-' + segment.month)}
|
||||||
<div
|
<div
|
||||||
@ -587,5 +586,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div data-id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
<div
|
||||||
|
data-id="lead-out"
|
||||||
|
class="relative"
|
||||||
|
style:height={relativeBottomOffset + 'px'}
|
||||||
|
data-label={segments.at(-1)?.dateFormatted}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { getTimes, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
@ -97,16 +97,11 @@
|
|||||||
// Note: There may be multiple months visible within the viewport at any given time.
|
// Note: There may be multiple months visible within the viewport at any given time.
|
||||||
let viewportTopMonthScrollPercent = $state(0);
|
let viewportTopMonthScrollPercent = $state(0);
|
||||||
// The timeline month intersecting the top position of the viewport
|
// The timeline month intersecting the top position of the viewport
|
||||||
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
|
let viewportTopMonth: ViewportTopMonth = $state(undefined);
|
||||||
// Overall scroll percentage through the entire timeline (0-1)
|
// Overall scroll percentage through the entire timeline (0-1)
|
||||||
let timelineScrollPercent: number = $state(0);
|
let timelineScrollPercent: number = $state(0);
|
||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
|
||||||
// 60 is the bottom spacer element at 60px
|
|
||||||
let bottomSectionHeight = 60;
|
|
||||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
|
||||||
let isInLeadOutSection = $state(false);
|
|
||||||
|
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||||
const maxMd = $derived(mobileDevice.maxMd);
|
const maxMd = $derived(mobileDevice.maxMd);
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
@ -230,41 +225,36 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getMaxScrollPercent = () => {
|
const scrollToSegmentPercentage = (segmentTop: number, segmentHeight: number, monthGroupScrollPercent: number) => {
|
||||||
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
const topOffset = segmentTop;
|
||||||
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
const maxScrollPercent = timelineManager.maxScrollPercent;
|
||||||
};
|
const delta = segmentHeight * monthGroupScrollPercent;
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
|
||||||
if (!scrollableElement || !timelineElement) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
timelineManager.topSectionHeight +
|
|
||||||
bottomSectionHeight +
|
|
||||||
(timelineElement.clientHeight - scrollableElement.clientHeight)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
|
||||||
const topOffset = monthGroup.top;
|
|
||||||
const maxScrollPercent = getMaxScrollPercent();
|
|
||||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
|
||||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
timelineManager.scrollTo(scrollToTop);
|
timelineManager.scrollTo(scrollToTop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
// 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
|
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||||
const onScrub: ScrubberListener = (scrubberData) => {
|
const onScrub: ScrubberListener = (scrubberData) => {
|
||||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||||
|
|
||||||
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
const leadIn = scrubberMonth === 'lead-in';
|
||||||
|
const leadOut = scrubberMonth === 'lead-out';
|
||||||
|
const noMonth = !scrubberMonth;
|
||||||
|
|
||||||
|
if (noMonth || timelineManager.limitedScroll) {
|
||||||
// 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 = timelineManager.maxScrollPercent;
|
||||||
const offset = maxScroll * overallScrollPercent;
|
const offset = maxScroll * overallScrollPercent * timelineManager.totalViewerHeight;
|
||||||
timelineManager.scrollTo(offset);
|
timelineManager.scrollTo(offset);
|
||||||
|
} else if (leadIn) {
|
||||||
|
scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent);
|
||||||
|
} else if (leadOut) {
|
||||||
|
scrollToSegmentPercentage(
|
||||||
|
timelineManager.topSectionHeight + timelineManager.assetsHeight,
|
||||||
|
timelineManager.bottomSectionHeight,
|
||||||
|
scrubberMonthScrollPercent,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const monthGroup = timelineManager.months.find(
|
const monthGroup = timelineManager.months.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
@ -272,50 +262,41 @@
|
|||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent);
|
scrollToSegmentPercentage(monthGroup.top, monthGroup.height, scrubberMonthScrollPercent);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 = () => {
|
||||||
isInLeadOutSection = false;
|
|
||||||
|
|
||||||
if (!scrollableElement) {
|
if (!scrollableElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (timelineManager.limitedScroll) {
|
||||||
// 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 = timelineManager.maxScroll;
|
||||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
|
||||||
|
|
||||||
|
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
|
timelineScrollPercent = 0;
|
||||||
|
|
||||||
let top = scrollableElement.scrollTop;
|
let top = scrollableElement.scrollTop;
|
||||||
if (top < timelineManager.topSectionHeight) {
|
let maxScrollPercent = timelineManager.maxScrollPercent;
|
||||||
// in the lead-in area
|
|
||||||
viewportTopMonth = undefined;
|
|
||||||
viewportTopMonthScrollPercent = 0;
|
|
||||||
const maxScroll = getMaxScroll();
|
|
||||||
|
|
||||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxScrollPercent = getMaxScrollPercent();
|
|
||||||
let found = false;
|
|
||||||
|
|
||||||
const monthsLength = timelineManager.months.length;
|
const monthsLength = timelineManager.months.length;
|
||||||
for (let i = -1; i < monthsLength + 1; i++) {
|
for (let i = -1; i < monthsLength + 1; i++) {
|
||||||
let monthGroup: TimelineYearMonth | undefined;
|
let monthGroup: ViewportTopMonth;
|
||||||
let monthGroupHeight = 0;
|
let monthGroupHeight = 0;
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
// lead-in
|
// lead-in
|
||||||
|
monthGroup = 'lead-in';
|
||||||
monthGroupHeight = timelineManager.topSectionHeight;
|
monthGroupHeight = timelineManager.topSectionHeight;
|
||||||
} else if (i === monthsLength) {
|
} else if (i === monthsLength) {
|
||||||
// lead-out
|
// lead-out
|
||||||
monthGroupHeight = bottomSectionHeight;
|
monthGroup = 'lead-out';
|
||||||
|
monthGroupHeight = timelineManager.bottomSectionHeight;
|
||||||
} else {
|
} else {
|
||||||
monthGroup = timelineManager.months[i].yearMonth;
|
monthGroup = timelineManager.months[i].yearMonth;
|
||||||
monthGroupHeight = timelineManager.months[i].height;
|
monthGroupHeight = timelineManager.months[i].height;
|
||||||
@ -334,18 +315,10 @@
|
|||||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
found = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
top = next;
|
top = next;
|
||||||
}
|
}
|
||||||
if (!found) {
|
|
||||||
isInLeadOutSection = true;
|
|
||||||
viewportTopMonth = undefined;
|
|
||||||
viewportTopMonthScrollPercent = 0;
|
|
||||||
timelineScrollPercent = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -540,8 +513,7 @@
|
|||||||
{timelineManager}
|
{timelineManager}
|
||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
timelineTopOffset={timelineManager.topSectionHeight}
|
timelineTopOffset={timelineManager.topSectionHeight}
|
||||||
timelineBottomOffset={bottomSectionHeight}
|
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||||
{isInLeadOutSection}
|
|
||||||
{timelineScrollPercent}
|
{timelineScrollPercent}
|
||||||
{viewportTopMonthScrollPercent}
|
{viewportTopMonthScrollPercent}
|
||||||
{viewportTopMonth}
|
{viewportTopMonth}
|
||||||
@ -580,7 +552,7 @@
|
|||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible
|
class:invisible
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
style:height={timelineManager.totalViewerHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
@ -636,11 +608,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<!-- spacer for leadout -->
|
<!-- spacer for leadout -->
|
||||||
<div
|
<div
|
||||||
class="h-[60px]"
|
style:height={timelineManager.bottomSectionHeight + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.assetsHeight}px,0)`}
|
||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -90,7 +90,7 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(timelineManager.timelineHeight).toBe(12_447.5);
|
expect(timelineManager.totalViewerHeight).toBe(12_507.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,9 @@ export class TimelineManager {
|
|||||||
isInitialized = $state(false);
|
isInitialized = $state(false);
|
||||||
months: MonthGroup[] = $state([]);
|
months: MonthGroup[] = $state([]);
|
||||||
topSectionHeight = $state(0);
|
topSectionHeight = $state(0);
|
||||||
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
bottomSectionHeight = $state(60);
|
||||||
|
assetsHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0));
|
||||||
|
totalViewerHeight = $derived(this.topSectionHeight + this.assetsHeight + this.bottomSectionHeight);
|
||||||
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||||
|
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
@ -62,6 +64,7 @@ export class TimelineManager {
|
|||||||
top: this.#scrollTop,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
}));
|
}));
|
||||||
|
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||||
|
|
||||||
initTask = new CancellableTask(
|
initTask = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
@ -383,8 +386,10 @@ export class TimelineManager {
|
|||||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||||
}
|
}
|
||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
|
if (changedWidth) {
|
||||||
this.#createScrubberMonths();
|
this.#createScrubberMonths();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#createScrubberMonths() {
|
#createScrubberMonths() {
|
||||||
this.scrubberMonths = this.months.map((month) => ({
|
this.scrubberMonths = this.months.map((month) => ({
|
||||||
@ -394,7 +399,7 @@ export class TimelineManager {
|
|||||||
title: month.monthGroupTitle,
|
title: month.monthGroupTitle,
|
||||||
height: month.height,
|
height: month.height,
|
||||||
}));
|
}));
|
||||||
this.scrubberTimelineHeight = this.timelineHeight;
|
this.scrubberTimelineHeight = this.totalViewerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
createLayoutOptions() {
|
createLayoutOptions() {
|
||||||
@ -408,6 +413,16 @@ export class TimelineManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get maxScrollPercent() {
|
||||||
|
const totalHeight = this.totalViewerHeight;
|
||||||
|
const max = (totalHeight - this.viewportHeight) / totalHeight;
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxScroll() {
|
||||||
|
return this.totalViewerHeight - this.viewportHeight;
|
||||||
|
}
|
||||||
|
|
||||||
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||||
let cancelable = true;
|
let cancelable = true;
|
||||||
if (options) {
|
if (options) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { TimelineDate, TimelineDateTime } from '$lib/utils/timeline-util';
|
import type { TimelineDate, TimelineDateTime, TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
||||||
|
|
||||||
|
export type ViewportTopMonth = TimelineYearMonth | undefined | 'lead-in' | 'lead-out';
|
||||||
|
|
||||||
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
||||||
|
|
||||||
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||||
@ -24,7 +24,7 @@ export type TimelineDateTime = TimelineDate & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (scrubberData: {
|
export type ScrubberListener = (scrubberData: {
|
||||||
scrubberMonth: { year: number; month: number };
|
scrubberMonth: ViewportTopMonth;
|
||||||
overallScrollPercent: number;
|
overallScrollPercent: number;
|
||||||
scrubberMonthScrollPercent: number;
|
scrubberMonthScrollPercent: number;
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user