diff --git a/frontend/src/lib/components/VirtualList.svelte b/frontend/src/lib/components/VirtualList.svelte index cd4ef1b9..d8847b8d 100644 --- a/frontend/src/lib/components/VirtualList.svelte +++ b/frontend/src/lib/components/VirtualList.svelte @@ -30,6 +30,7 @@ + + + +
+
+ {#each visible as r, i (r.key ?? band.first + i)} + {@render row(r, band.first + i)} + {/each} +
+
+ + diff --git a/frontend/src/lib/composables/useVirtualWindow.svelte.ts b/frontend/src/lib/composables/useVirtualWindow.svelte.ts new file mode 100644 index 00000000..9f7db2d3 --- /dev/null +++ b/frontend/src/lib/composables/useVirtualWindow.svelte.ts @@ -0,0 +1,81 @@ +/** + * Shared scroll-window tracker for windowing lists. Reactively reports how far a + * list element's top has scrolled above the nearest scrollable ancestor's + * viewport (`aboveBy`, px) and that viewport's height (`viewportH`). + * + * `VirtualList` (uniform rows) and `VirtualRows` (variable-height, section-aware + * rows) each derive their own visible slice from these two signals, so the + * scroll-ancestor detection and the rAF-throttled measurement live in exactly + * one place. Ancestor-based (not its own scroll box) so it drops into an + * existing scroll container without changing the single-scrollbar UX. + */ +export class VirtualWindow { + /** Pixels of the list scrolled above the viewport top (negative until reached). */ + aboveBy = $state(0); + /** Height of the scrollable viewport in px. */ + viewportH = $state(0); + + #root: HTMLElement | null = null; + #scroller: HTMLElement | null = null; + #ticking = false; + + /** Nearest scrollable ancestor, or null to mean the window/document. */ + #findScroller(el: HTMLElement): HTMLElement | null { + let node = el.parentElement; + while (node) { + const oy = getComputedStyle(node).overflowY; + if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return node; + node = node.parentElement; + } + return null; + } + + #measure = (): void => { + const root = this.#root; + if (!root) return; + const rootTop = root.getBoundingClientRect().top; + if (this.#scroller) { + this.aboveBy = this.#scroller.getBoundingClientRect().top - rootTop; + this.viewportH = this.#scroller.clientHeight; + } else { + this.aboveBy = -rootTop; + this.viewportH = window.innerHeight; + } + }; + + #onScroll = (): void => { + if (this.#ticking) return; + this.#ticking = true; + requestAnimationFrame(() => { + this.#ticking = false; + this.#measure(); + }); + }; + + /** Begin observing `root`; returns a teardown to call from `onMount`. */ + observe(root: HTMLElement): () => void { + this.#root = root; + this.#scroller = this.#findScroller(root); + const target: EventTarget = this.#scroller ?? window; + target.addEventListener('scroll', this.#onScroll, { passive: true }); + window.addEventListener('resize', this.#onScroll, { passive: true }); + const ro = new ResizeObserver(this.#onScroll); + if (this.#scroller) ro.observe(this.#scroller); + ro.observe(root); + this.#measure(); + return () => { + target.removeEventListener('scroll', this.#onScroll); + window.removeEventListener('resize', this.#onScroll); + ro.disconnect(); + }; + } + + /** Force a synchronous re-measure (e.g. just after rows first render). */ + remeasure(): void { + this.#measure(); + } +} + +export function useVirtualWindow(): VirtualWindow { + return new VirtualWindow(); +} diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index 3dab174c..91a5e4f9 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -4,6 +4,7 @@ import PeopleView from '$lib/components/PeopleView.svelte'; import PhotoLightbox from '$lib/components/PhotoLightbox.svelte'; import PlacesMap from '$lib/components/PlacesMap.svelte'; + import VirtualRows from '$lib/components/VirtualRows.svelte'; import { useSelection } from '$lib/composables/useSelection.svelte'; import { errorToast } from '$lib/utils/errors'; import { onMount } from 'svelte'; @@ -138,6 +139,60 @@ return rows; } + // ── Virtualized row model ──────────────────────────────────────────────── + // Flatten the groups into a single list of fixed-height rows (a date header + // or a strip of sized tiles), so VirtualRows can window the whole timeline — + // only the rows near the viewport are mounted, regardless of library size. + const SQUARE_GAP = 4; // .25rem, matches the old grid gap + const SQUARE_MIN = 144; // 9rem minmax floor + const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom + const HEADER_H = 44; + + type PhotoRow = + | { kind: 'header'; key: string; height: number; label: string; count: number } + | { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] }; + + const photoRows = $derived.by(() => { + const W = gridWidth; + if (W <= 0) return []; + const rows: PhotoRow[] = []; + const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP))); + const cell = (W - (cols - 1) * SQUARE_GAP) / cols; + for (const g of groups) { + rows.push({ + kind: 'header', + key: `h:${g.key}`, + height: HEADER_H, + label: g.label, + count: g.photos.length + }); + if (layoutMode === 'justified') { + const jrows = justifiedRows(g.photos, W); + for (let ri = 0; ri < jrows.length; ri++) { + rows.push({ + kind: 'tiles', + key: `${g.key}:j${ri}`, + height: jrows[ri].height + JUSTIFIED_GAP, + gap: JUSTIFIED_GAP, + tiles: jrows[ri].tiles + }); + } + } else { + for (let i = 0; i < g.photos.length; i += cols) { + const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell })); + rows.push({ + kind: 'tiles', + key: `${g.key}:s${i}`, + height: cell + SQUARE_GAP, + gap: SQUARE_GAP, + tiles + }); + } + } + } + return rows; + }); + async function loadMore() { if (loading || exhausted) return; loading = true; @@ -408,31 +463,23 @@ {:else}
- {#each groups as group (group.key)} -

- {group.label} {group.photos.length} -

- {#if layoutMode === 'justified' && gridWidth > 0} - {#each justifiedRows(group.photos, gridWidth) as row, ri (group.key + '-' + ri)} -
- {#each row.tiles as cell (cell.file.id)} - {@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)} - {/each} -
- {/each} - {:else} -
    - {#each group.photos as photo (photo.id)} -
  • - {@render tile(photo)} -
  • - {/each} -
- {/if} - {/each} + {#if photoRows.length} + + {#snippet row(r)} + {#if r.kind === 'header'} +
+ {r.label} {r.count} +
+ {:else} +
+ {#each r.tiles as cell (cell.file.id)} + {@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)} + {/each} +
+ {/if} + {/snippet} +
+ {/if}
{/if} @@ -566,8 +613,13 @@ padding: 0 1rem; } + /* Date header — fixed height (set inline) so the virtualizer's offset table + matches the rendered layout exactly. */ .photos-group { - margin: var(--space-4) 0 var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0; font-size: 1rem; color: var(--color-text-heading); } @@ -578,20 +630,11 @@ font-weight: var(--weight-normal); } - .photos { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); - gap: 0.25rem; - } - - /* Justified rows: a flex row of aspect-preserving tiles. */ - .photos-jrow { + /* A horizontal strip of explicitly-sized tiles — one virtualized row, used by + both the square and justified layouts (the bottom gap is baked into the + row's declared height). */ + .photos-strip { display: flex; - gap: 8px; - margin-bottom: 8px; } .photo-tile { @@ -601,16 +644,6 @@ background: var(--color-bg-muted); } - .photos__cell--square, - .photos__cell--square .photo-tile { - aspect-ratio: 1; - height: 100%; - } - - .photos__cell--square { - list-style: none; - } - .photo-tile.selected { outline: 3px solid var(--color-accent); outline-offset: -3px;