fix: responsive: timeline glitch and keyboard-accessible scrubber (#17556)

* fix: responsive: timeline glitch

* lint

* fix margin-right on mobile

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis
2025-04-14 12:56:40 -04:00
committed by GitHub
parent 664c99278a
commit 5a51ad3622
8 changed files with 427 additions and 142 deletions
+116 -48
View File
@@ -17,7 +17,7 @@ import {
TimeBucketSize,
type AssetResponseDto,
} from '@immich/sdk';
import { debounce, isEqual, throttle } from 'lodash-es';
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@@ -30,10 +30,6 @@ const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
const THUMBNAIL_HEIGHT = 235;
const GAP = 12;
const HEADER = 49; //(1.5rem)
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string;
@@ -83,8 +79,8 @@ class IntersectingAsset {
}
const store = this.#group.bucket.store;
const topWindow = store.visibleWindow.top + HEADER - INTERSECTION_EXPAND_TOP;
const bottomWindow = store.visibleWindow.bottom + HEADER + INTERSECTION_EXPAND_BOTTOM;
const topWindow = store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP;
const bottomWindow = store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM;
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
const positionBottom = positionTop + this.position.height;
@@ -97,7 +93,7 @@ class IntersectingAsset {
position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state();
id: string = $derived.by(() => this.asset!.id);
id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
this.#group = group;
@@ -230,6 +226,7 @@ export type ViewportXY = Viewport & {
export class AssetBucket {
// --- public ---
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
dateGroups: AssetDateGroup[] = $state([]);
readonly store: AssetStore;
@@ -243,8 +240,10 @@ export class AssetBucket {
*/
#bucketHeight: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
// --- should be private, but is used by AssetStore ---
bucketCount: number = $derived(
@@ -282,6 +281,7 @@ export class AssetBucket {
this.isLoaded = true;
},
() => {
this.dateGroups = [];
this.isLoaded = false;
},
this.handleLoadError,
@@ -401,8 +401,12 @@ export class AssetBucket {
}
if (dateGroup) {
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
dateGroup.intersetingAssets.push(intersectingAsset);
changedDateGroups.add(dateGroup);
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) {
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`);
} else {
dateGroup.intersetingAssets.push(intersectingAsset);
changedDateGroups.add(dateGroup);
}
} else {
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
@@ -440,29 +444,36 @@ export class AssetBucket {
}
set bucketHeight(height: number) {
const { store } = this;
const { store, percent } = this;
const index = store.buckets.indexOf(this);
const bucketHeightDelta = height - this.#bucketHeight;
this.#bucketHeight = height;
const prevBucket = store.buckets[index - 1];
if (prevBucket) {
this.#top = prevBucket.#top + prevBucket.#bucketHeight;
}
if (bucketHeightDelta) {
let cursor = index + 1;
while (cursor < store.buckets.length) {
const nextBucket = this.store.buckets[cursor];
nextBucket.#top += bucketHeightDelta;
cursor++;
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
const bucket = this.store.buckets[cursor];
const newTop = bucket.#top + bucketHeightDelta;
if (bucket.#top !== newTop) {
bucket.#top = newTop;
}
}
this.#bucketHeight = height;
if (store.topIntersectingBucket) {
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
// if the bucket is 'before' the last intersecting bucket in the sliding window
// then adjust the scroll position by the delta, to compensate for the bucket
// size adjustment
if (currentIndex > 0 && index <= currentIndex) {
store.compensateScrollCallback?.(bucketHeightDelta);
if (currentIndex > 0) {
if (index < currentIndex) {
store.compensateScrollCallback?.({ delta: bucketHeightDelta });
} else if (currentIndex == currentIndex && percent > 0) {
const top = this.top + height * percent;
store.compensateScrollCallback?.({ top });
}
}
}
}
@@ -470,10 +481,7 @@ export class AssetBucket {
return this.#bucketHeight;
}
set top(top: number) {
this.#top = top;
}
get top() {
get top(): number {
return this.#top + this.store.topSectionHeight;
}
@@ -490,7 +498,7 @@ export class AssetBucket {
for (const group of this.dateGroups) {
const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
if (intersectingAsset) {
return this.top + group.top + intersectingAsset.position!.top + HEADER;
return this.top + group.top + intersectingAsset.position!.top + this.store.headerHeight;
}
}
return -1;
@@ -556,7 +564,7 @@ export class AssetStore {
scrubberTimelineHeight: number = $state(0);
// -- should be private, but used by AssetBucket
compensateScrollCallback: ((delta: number) => void) | undefined;
compensateScrollCallback: (({ delta, top }: { delta?: number; top?: number }) => void) | undefined;
topIntersectingBucket: AssetBucket | undefined = $state();
visibleWindow = $derived.by(() => ({
@@ -581,13 +589,16 @@ export class AssetStore {
// --- private
static #INIT_OPTIONS = {};
#rowHeight = 235;
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#pendingChanges: PendingChange[] = [];
#unsubscribers: Unsubscriber[] = [];
#rowHeight = $state(235);
#headerHeight = $state(49);
#gap = $state(12);
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
#scrolling = $state(false);
@@ -597,6 +608,42 @@ export class AssetStore {
constructor() {}
set headerHeight(value) {
if (this.#headerHeight == value) {
return;
}
this.#headerHeight = value;
this.refreshLayout();
}
get headerHeight() {
return this.#headerHeight;
}
set gap(value) {
if (this.#gap == value) {
return;
}
this.#gap = value;
this.refreshLayout();
}
get gap() {
return this.#gap;
}
set rowHeight(value) {
if (this.#rowHeight == value) {
return;
}
this.#rowHeight = value;
this.refreshLayout();
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) {
this.#scrolling = value;
if (value) {
@@ -624,7 +671,6 @@ export class AssetStore {
const changed = value !== this.#viewportWidth;
this.#viewportWidth = value;
this.suspendTransitions = true;
this.#rowHeight = value < 850 ? 100 : 235;
// side-effect - its ok!
void this.#updateViewportGeometry(changed);
}
@@ -724,29 +770,51 @@ export class AssetStore {
let topIntersectingBucket = undefined;
for (const bucket of this.buckets) {
this.#updateIntersection(bucket);
if (!topIntersectingBucket && bucket.intersecting) {
if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) {
topIntersectingBucket = bucket;
}
}
if (this.topIntersectingBucket !== topIntersectingBucket) {
this.topIntersectingBucket = topIntersectingBucket;
}
for (const bucket of this.buckets) {
if (bucket === this.topIntersectingBucket) {
this.topIntersectingBucket.percent = clamp(
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
0,
1,
);
} else {
bucket.percent = 0;
}
}
}
#updateIntersection(bucket: AssetBucket) {
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
const bucketTop = bucket.top;
const bucketBottom = bucketTop + bucket.bucketHeight;
const topWindow = this.visibleWindow.top - INTERSECTION_EXPAND_TOP;
const bottomWindow = this.visibleWindow.bottom + INTERSECTION_EXPAND_BOTTOM;
const topWindow = this.visibleWindow.top - expandTop;
const bottomWindow = this.visibleWindow.bottom + expandBottom;
// a bucket intersections if
// 1) bucket's bottom is in the visible range -or-
// 2) bucket's bottom is in the visible range -or-
// 3) bucket's top is above visible range and bottom is below visible range
bucket.intersecting =
return (
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
(bucketTop < topWindow && bucketBottom >= bottomWindow);
(bucketTop < topWindow && bucketBottom >= bottomWindow)
);
}
#updateIntersection(bucket: AssetBucket) {
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
}
bucket.intersecting = actuallyIntersecting || preIntersecting;
bucket.actuallyIntersecting = actuallyIntersecting;
}
#processPendingChanges = throttle(() => {
@@ -763,7 +831,7 @@ export class AssetStore {
this.#pendingChanges = [];
}, 2500);
setCompensateScrollCallback(compensateScrollCallback?: (delta: number) => void) {
setCompensateScrollCallback(compensateScrollCallback?: ({ delta, top }: { delta?: number; top?: number }) => void) {
this.compensateScrollCallback = compensateScrollCallback;
}
@@ -800,11 +868,6 @@ export class AssetStore {
this.#updateViewportGeometry(false);
}
updateLayoutOptions(options: AssetStoreLayoutOptions) {
this.#rowHeight = options.rowHeight;
this.refreshLayout();
}
async #init(options: AssetStoreOptions) {
// doing the following outside of the task reduces flickr
this.isInitialized = false;
@@ -890,9 +953,9 @@ export class AssetStore {
// optimize - if bucket already has data, no need to create estimates
const viewportWidth = this.viewportWidth;
if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
const height = 51 + Math.max(1, rows) * this.#rowHeight;
bucket.bucketHeight = height;
}
return;
@@ -918,7 +981,7 @@ export class AssetStore {
assetGroup.layout(options);
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
if (dateGroupCol > 0) {
rowSpaceRemaining[dateGroupRow] -= GAP;
rowSpaceRemaining[dateGroupRow] -= this.gap;
}
if (rowSpaceRemaining[dateGroupRow] >= 0) {
assetGroup.row = dateGroupRow;
@@ -928,7 +991,7 @@ export class AssetStore {
dateGroupCol++;
cummulativeWidth += assetGroup.width + GAP;
cummulativeWidth += assetGroup.width + this.gap;
} else {
// starting a new row, we need to update the last col of the previous row
cummulativeWidth = 0;
@@ -942,10 +1005,10 @@ export class AssetStore {
dateGroupCol++;
cummulativeHeight += lastRowHeight;
assetGroup.top = cummulativeHeight;
cummulativeWidth += assetGroup.width + GAP;
cummulativeWidth += assetGroup.width + this.gap;
lastRow = assetGroup.row - 1;
}
lastRowHeight = assetGroup.height + HEADER;
lastRowHeight = assetGroup.height + this.headerHeight;
}
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
cummulativeHeight += lastRowHeight;
@@ -974,6 +1037,11 @@ export class AssetStore {
}
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
if (bucket.getFirstAsset()) {
// this happens when a bucket was created by an event instead of via a loadBucket call
// so no need to load the bucket, it already has assets
return;
}
const assets = await getTimeBucket(
{
...this.#options,