mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 01:11:16 +03: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:
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
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 { 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 { mdiPlay } from '@mdi/js';
|
||||
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 */
|
||||
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;
|
||||
viewportTopMonth?: ViewportTopMonth;
|
||||
|
||||
/** 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 */
|
||||
@@ -47,7 +46,6 @@
|
||||
timelineScrollPercent = 0,
|
||||
viewportTopMonthScrollPercent = 0,
|
||||
viewportTopMonth = undefined,
|
||||
isInLeadOutSection = false,
|
||||
onScrub = undefined,
|
||||
onScrubKeyDown = undefined,
|
||||
startScrub = undefined,
|
||||
@@ -94,11 +92,19 @@
|
||||
});
|
||||
|
||||
const toScrollFromMonthGroupPercentage = (
|
||||
scrubberMonth: { year: number; month: number } | undefined,
|
||||
scrubberMonth: ViewportTopMonth,
|
||||
scrubberMonthPercent: 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 match = false;
|
||||
for (const segment of segments) {
|
||||
@@ -113,23 +119,16 @@
|
||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||
}
|
||||
return offset;
|
||||
} else if (isInLeadOutSection) {
|
||||
let offset = relativeTopOffset;
|
||||
for (const segment of segments) {
|
||||
offset += segment.height;
|
||||
}
|
||||
offset += scrubOverallPercent * relativeBottomOffset;
|
||||
return offset;
|
||||
} else {
|
||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||
}
|
||||
};
|
||||
let scrollY = $derived(
|
||||
const 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));
|
||||
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
|
||||
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
const relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||
|
||||
type Segment = {
|
||||
count: number;
|
||||
@@ -173,14 +172,13 @@
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
}
|
||||
if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
||||
if (segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
||||
segment.hasDot = true;
|
||||
dotHeight = 0;
|
||||
}
|
||||
|
||||
height += segment.height;
|
||||
dotHeight += segment.height;
|
||||
}
|
||||
dotHeight += segment.height;
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
@@ -197,7 +195,13 @@
|
||||
}
|
||||
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) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -215,7 +219,22 @@
|
||||
}
|
||||
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[]) => {
|
||||
if (ids.length === 0) {
|
||||
@@ -308,38 +327,23 @@
|
||||
isHoverOnPaddingTop = isOnPaddingTop;
|
||||
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
const scrubData = {
|
||||
scrubberMonth: segmentDate,
|
||||
overallScrollPercent: toTimelineY(hoverY),
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
};
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void onScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void startScrub?.(scrubData);
|
||||
void onScrub?.(scrubData);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void stopScrub?.(scrubData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void onScrub?.(scrubData);
|
||||
};
|
||||
const getTouch = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
@@ -559,13 +563,8 @@
|
||||
class="relative"
|
||||
style:height={relativeTopOffset + 'px'}
|
||||
data-id="lead-in"
|
||||
data-segment-year-month={segments.at(0)?.year + '-' + segments.at(0)?.month}
|
||||
data-label={segments.at(0)?.dateFormatted}
|
||||
>
|
||||
{#if relativeTopOffset > 6}
|
||||
<div class="absolute end-3 h-[4px] w-[4px] rounded-full bg-gray-300"></div>
|
||||
{/if}
|
||||
</div>
|
||||
></div>
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment (segment.year + '-' + segment.month)}
|
||||
<div
|
||||
@@ -587,5 +586,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
Reference in New Issue
Block a user