diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index 1a84f21729..1564dd90d0 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -1,4 +1,5 @@ import { shortcuts } from '$lib/actions/shortcut'; +import { getFocusable } from '$lib/utils/focus-util'; import { tick } from 'svelte'; interface Options { @@ -8,9 +9,6 @@ interface Options { active?: boolean; } -const selectors = - 'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)'; - export function focusTrap(container: HTMLElement, options?: Options) { const triggerElement = document.activeElement; @@ -21,7 +19,7 @@ export function focusTrap(container: HTMLElement, options?: Options) { }; const setInitialFocus = () => { - const focusableElement = container.querySelector(selectors); + const focusableElement = getFocusable(container)[0]; // Use tick() to ensure focus trap works correctly inside void tick().then(() => focusableElement?.focus()); }; @@ -30,11 +28,11 @@ export function focusTrap(container: HTMLElement, options?: Options) { setInitialFocus(); } - const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => { - const focusableElements = container.querySelectorAll(selectors); + const getFocusableElements = () => { + const focusableElements = getFocusable(container); return [ - focusableElements.item(0), // - focusableElements.item(focusableElements.length - 1), + focusableElements.at(0), // + focusableElements.at(-1), ]; }; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index fa20cf9a67..93a4e3c6cc 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -25,6 +25,7 @@ import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; import { onMount } from 'svelte'; + import { getFocusable } from '$lib/utils/focus-util'; interface Props { asset: AssetResponseDto; @@ -222,10 +223,30 @@ if (evt.key === 'x') { onSelect?.(asset); } + if (document.activeElement === focussableElement && evt.key === 'Escape') { + const focusable = getFocusable(document); + const index = focusable.indexOf(focussableElement); + + let i = index + 1; + while (i !== index) { + const next = focusable[i]; + if (next.dataset.thumbnailFocusContainer !== undefined) { + if (i === focusable.length - 1) { + i = 0; + } else { + i++; + } + continue; + } + next.focus(); + break; + } + } }} onclick={handleClick} bind:this={focussableElement} onfocus={handleFocus} + data-thumbnail-focus-container data-testid="container-with-tabindex" tabindex={0} role="link" diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 70350a35b7..ccba4c88a8 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -78,13 +78,19 @@ let scrubBucketPercent = $state(0); let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubOverallPercent: number = $state(0); + let scrubberWidth = $state(0); // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; let leadout = $state(false); + const maxMd = $derived(mobileDevice.maxMd); const usingMobileDevice = $derived(mobileDevice.pointerCoarse); + $effect(() => { + assetStore.rowHeight = maxMd ? 100 : 235; + }); + const scrollTo = (top: number) => { element?.scrollTo({ top }); showSkeleton = false; @@ -162,7 +168,13 @@ const updateIsScrolling = () => (assetStore.scrolling = true); // note: don't throttle, debounch, or otherwise do this function async - it causes flicker const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0); - const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta); + const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => { + if (delta) { + element?.scrollBy(0, delta); + } else if (top) { + element?.scrollTo({ top }); + } + }; const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height); onMount(() => { @@ -267,10 +279,21 @@ bucket = assetStore.buckets[i]; bucketHeight = assetStore.buckets[i].bucketHeight; } + let next = top - bucketHeight * maxScrollPercent; - if (next < 0) { + // instead of checking for < 0, add a little wiggle room for subpixel resolution + if (next < -1 && bucket) { scrubBucket = bucket; - scrubBucketPercent = top / (bucketHeight * maxScrollPercent); + + // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage + scrubBucketPercent = Math.max(0, top / (bucketHeight * maxScrollPercent)); + + // compensate for lost precision/rouding errors advance to the next bucket, if present + if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) { + scrubBucket = assetStore.buckets[i + 1]; + scrubBucketPercent = 0; + } + found = true; break; } @@ -689,7 +712,6 @@ {#if assetStore.buckets.length > 0} { evt.preventDefault(); let amount = 50; @@ -720,12 +743,8 @@
((assetStore.viewportWidth = v), updateSlidingWindow())} @@ -763,7 +782,7 @@ style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:width="100%" > - + {:else if display}
{/if} {/each} - + +
diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte index 2538ee49d4..dd32d68842 100644 --- a/web/src/lib/components/photos-page/skeleton.svelte +++ b/web/src/lib/components/photos-page/skeleton.svelte @@ -13,7 +13,11 @@ > {title} -
+
diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index e9a6c4efe0..f523406a31 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -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[0]; export type AssetStoreOptions = Omit & { 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, diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts new file mode 100644 index 0000000000..8ad774f7ac --- /dev/null +++ b/web/src/lib/utils/focus-util.ts @@ -0,0 +1,4 @@ +const selectors = + 'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)'; + +export const getFocusable = (container: ParentNode) => [...container.querySelectorAll(selectors)]; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index feda36fa01..ea10c45444 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -32,6 +32,9 @@