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 ?
: null}
+ {painted.map((photo) => (
{tile(photo, () => void open(photo))}
@@ -1104,6 +1125,7 @@ export function Library() {
))}
+ {wall.below > 0 ? : null}
>
) : 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;