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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user