diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index e127cba..7bc2408 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react'; import { useI18n } from './i18n/I18nProvider'; import { go } from './nav'; import { api } from './api'; @@ -79,6 +79,13 @@ const TILE_STEP = 140; // meets pictures rather than placeholders, few enough that the count does not // matter: what is drawn is this many, whatever the shelf holds. const STRIP_KEEP = 8; +// How many rows of the wall past each edge of its viewport are drawn. A row of +// cards is a couple of hundred pixels tall, so this is the row under the eye and +// two beyond it — enough that a flick lands on pictures. +const WALL_ROWS = 2; +// What `.adm-grid` leaves between its rows. A spacer has to stand the same +// distance past the last row it replaces as the row itself would have. +const WALL_GAP = 12; // The hours a frame's own clock can read, and what the time filter offers: the // whole day in the 24-hour form a camera writes into EXIF. const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -627,11 +634,6 @@ export function Library() { () => shown.slice(Math.min(strip.from, shown.length), Math.min(strip.to, shown.length)), [shown, strip.from, strip.to] ); - // The wall is the other list and holds every frame on purpose; the strip's window - // is what the URLs are made for only while the strip is up. The frame on the stage - // is given one wherever it sits — the stage is not the strip. - const urls = useThumbUrls(view === 'preview' ? drawn : shown, frame); - // The years the open folder holds, newest first: the filter offers the years // there are frames to find in rather than a century of empty ones. const years = useMemo( @@ -659,6 +661,16 @@ export function Library() { return order === 'oldest' ? [...rows].reverse() : rows; }, [shown, minStar, year, from, to, order]); + // The wall is the other list, and it draws its rows the way the strip draws its + // tiles: the ones around the eye, with a spacer standing in for the rest. A card + // is a card, an `` and an object URL of its own, and twenty thousand of them + // is seven seconds of dead screen — the URL is only a tenth of it; the rest is + // the DOM and the number the browser formats for each date. The frame on the + // stage is given a picture wherever it sits — the stage is not the wall. + const wall = useWallWindow(listed.length, view === 'grid'); + const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)); + const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame); + // A frame's score: click a star to give it, click the star it already has to // take the score back. The catalogue holds it, so it is the same score the next // visit reads — and the list on screen is told, rather than read back whole for @@ -753,13 +765,15 @@ export function Library() { // The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose // preview the camera never wrote. A tile only carries a handler where a click // on the picture means "open it" — in the strip the click belongs to the frame - // it raises, not to the studio. + // it raises, not to the studio. The word is only for a frame that has no + // thumbnail to give: one that has one and has not been handed it yet — the wall + // hands pictures out only around the eye — keeps the box and says nothing. const tile = (photo: LibraryPhoto, onPick?: () => void) => urls[photo.id] ? ( ) : (
- RAW + {photo.thumb ? '' : 'RAW'}
); @@ -1087,8 +1101,15 @@ export function Library() { {t('lib.count', { n: listed.length })} -
- {listed.map((photo) => ( + {/* The wall is the shelf's own length, so the scrollbar on + its right edge still reaches every frame; what is drawn + is the rows around the eye, with a spacer standing in + for the rows above and below. A card is a card, an + and an object URL of its own, and twenty thousand + of them is a dead screen. */} +
+ {wall.above > 0 ?
) : frame ? ( @@ -1393,3 +1415,76 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject` and an object URL +// of its own; twenty thousand of them is a dead screen for as long as they take to +// build. `on` is the wall being up, since the element measured is its own. +function useWallWindow(count: number, on: boolean): { ref: RefObject; from: number; to: number; above: number; below: number } { + const ref = useRef(null); + // The first window is a screenful: the wall has not been measured yet. + const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 })); + const measureRef = useRef<() => void>(() => {}); + // The window over a scroll container is taken rather than kept, so it is taken + // after every render: the first pass draws a screenful, the next reads the row's + // step off it and lays out the spacers, and the pass after that leaves both + // alone. The listeners are for the two things no render can see coming — a + // scroll of the wall, and the window itself changing size. + useEffect(() => { + if (!on) return; + const el = ref.current; + if (!el) return; + const measure = () => { + const cards = el.querySelectorAll(':scope > .adm-card'); + const first = cards[0]; + let cols = 0; + if (first) while (cols < cards.length && cards[cols].offsetTop === first.offsetTop) cols++; + // One row, or a wall not yet drawn: open on the first screenful and let the + // next pass read the step off what it drew. + if (cards.length <= cols) { + const to = Math.min(count, 24); + setWin((prev) => (prev.from === 0 && prev.to === to && !prev.above && !prev.below ? prev : { from: 0, to, above: 0, below: 0 })); + return; + } + // The step from one row to the next, taken as the drawn run's own average. + // The cards do NOT all stand the same height — a caption that wraps makes + // its row taller — and `offsetTop` is rounded to whole pixels, so the + // smallest step would be a pixel short on every one of thousands of rows. + // The average of the rows in hand is the honest guess at the ones that are + // not in hand. + const rowsDrawn = Math.floor(cards.length / cols); + if (rowsDrawn < 2) return; + const pitch = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1); + if (!(pitch > 0)) return; + const rows = Math.ceil(count / cols); + const row0 = Math.max(0, Math.floor(el.scrollTop / pitch) - WALL_ROWS); + const row1 = Math.min(rows, Math.ceil((el.scrollTop + el.clientHeight) / pitch) + WALL_ROWS); + const from = row0 * cols; + const to = Math.min(count, row1 * cols); + // A spacer's own height keeps the gap the grid would have left under the + // last row it stands in for. + const above = row0 ? row0 * pitch - WALL_GAP : 0; + const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0; + setWin((prev) => + prev.from === from && prev.to === to && prev.above === above && prev.below === below ? prev : { from, to, above, below } + ); + }; + measureRef.current = measure; + const onScroll = () => measure(); + el.addEventListener('scroll', onScroll, { passive: true }); + const ro = new ResizeObserver(onScroll); + ro.observe(el); + return () => { + measureRef.current = () => {}; + el.removeEventListener('scroll', onScroll); + ro.disconnect(); + }; + }, [count, on]); + useLayoutEffect(() => { + measureRef.current(); + }); + return { ref, from: win.from, to: win.to, above: win.above, below: win.below }; +} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index fa83aca..cbf6fc1 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1003,6 +1003,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } It is given the width they would have taken, and it must not shrink, or the scrollbar would measure the window instead of the shelf. */ .adm-thumb-gap { flex: 0 0 auto; } +/* The wall's stand-in for the rows it is not drawing: a run of cards' worth of + height, spanning the whole grid so nothing lands beside it. */ +.adm-wall-gap { grid-column: 1 / -1; } .adm-thumb { flex: 0 0 132px; margin: 0;