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.
This commit is contained in:
midzelis
2025-09-15 13:37:41 +00:00
parent e42886b767
commit 3e9f713d81
20 changed files with 1940 additions and 1001 deletions
+90 -5
View File
@@ -23,11 +23,12 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number;
};
export type ScrubberListener = (
scrubberMonth: { year: number; month: number },
overallScrollPercent: number,
scrubberMonthScrollPercent: number,
) => void | Promise<void>;
export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number };
overallScrollPercent: number;
scrubberMonthScrollPercent: number;
scrollToFunction?: (top: number) => void;
}) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
@@ -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<true>;
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<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
}
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;
}