web: the wall draws the rows under the eye, not the shelf — twenty thousand cards no longer cost seven seconds of dead screen
The strip stopped building every tile two commits ago, but the wall was left holding every frame on purpose: the grid view was the one list that still made a card per frame. A card is an `<article>`, an `<img>`, an object URL and a date the browser formats, and on a roll of twenty thousand the first one did not paint for 7410ms — 19998 cards and 19998 pictures, 7007ms of long tasks, the largest of them 4882ms. The object URL is only about a tenth of it (83µs each); the rest is the DOM and the i18n date formatting behind every card. The wall now draws the rows under its viewport and `WALL_ROWS` either side, measured from `scrollTop` and the client height on scroll and on resize, and two spacers stand in for the rows that are not drawn, each as tall as the rows it replaces so the scrollbar still spans the whole shelf. The step from one row to the next is the average of the rows in hand rather than the smallest of them: the cards do not all stand the same height — a caption that wraps makes its row taller (274.34 / 259.36 / 274.36 measured) — and `offsetTop` is rounded to whole pixels, so the least step was a pixel short on every one of thousands of rows and the scrollbar came up 1436px shy of the end. The average puts `scrollHeight` back on the number the fully drawn wall had, to the pixel (1085426). Measured against a seeded roll of 20000: the first card 7410ms → 212ms, cards drawn 19998 → 25, long tasks 7007ms → none, and the scrollbar unchanged. A check on a roll of 4000 walks the wall end to end — the last frame drawn at the bottom, the first drawn again at the top, spacer 215719px either side, `scrollHeight` the same 217076 at both ends, no drift — and coming back to the strip still leaves eighteen tiles. A card that has a thumbnail and has not been handed its URL yet now keeps its box in silence instead of saying RAW, since the wall hands pictures out only around the eye; the word is left for a frame that has no thumbnail to give.
This commit is contained in:
+105
-10
@@ -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 { useI18n } from './i18n/I18nProvider';
|
||||||
import { go } from './nav';
|
import { go } from './nav';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -79,6 +79,13 @@ const TILE_STEP = 140;
|
|||||||
// meets pictures rather than placeholders, few enough that the count does not
|
// meets pictures rather than placeholders, few enough that the count does not
|
||||||
// matter: what is drawn is this many, whatever the shelf holds.
|
// matter: what is drawn is this many, whatever the shelf holds.
|
||||||
const STRIP_KEEP = 8;
|
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
|
// 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.
|
// whole day in the 24-hour form a camera writes into EXIF.
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
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.slice(Math.min(strip.from, shown.length), Math.min(strip.to, shown.length)),
|
||||||
[shown, strip.from, strip.to]
|
[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
|
// 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.
|
// there are frames to find in rather than a century of empty ones.
|
||||||
const years = useMemo(
|
const years = useMemo(
|
||||||
@@ -659,6 +661,16 @@ export function Library() {
|
|||||||
return order === 'oldest' ? [...rows].reverse() : rows;
|
return order === 'oldest' ? [...rows].reverse() : rows;
|
||||||
}, [shown, minStar, year, from, to, order]);
|
}, [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 `<img>` 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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) =>
|
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
|
||||||
urls[photo.id] ? (
|
urls[photo.id] ? (
|
||||||
<img src={urls[photo.id]} alt="" loading="lazy" onClick={onPick} title={onPick ? t('lib.open') : undefined} />
|
<img src={urls[photo.id]} alt="" loading="lazy" onClick={onPick} title={onPick ? t('lib.open') : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
|
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
|
||||||
RAW
|
{photo.thumb ? '' : 'RAW'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1087,8 +1101,15 @@ export function Library() {
|
|||||||
{t('lib.count', { n: listed.length })}
|
{t('lib.count', { n: listed.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="adm-grid">
|
{/* The wall is the shelf's own length, so the scrollbar on
|
||||||
{listed.map((photo) => (
|
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
|
||||||
|
<img> and an object URL of its own, and twenty thousand
|
||||||
|
of them is a dead screen. */}
|
||||||
|
<div className="adm-grid" ref={wall.ref}>
|
||||||
|
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||||||
|
{painted.map((photo) => (
|
||||||
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
||||||
{tile(photo, () => void open(photo))}
|
{tile(photo, () => void open(photo))}
|
||||||
<div className="adm-meta">
|
<div className="adm-meta">
|
||||||
@@ -1104,6 +1125,7 @@ export function Library() {
|
|||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : frame ? (
|
) : frame ? (
|
||||||
@@ -1393,3 +1415,76 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
|
|||||||
}, [count, on]);
|
}, [count, on]);
|
||||||
return { ref, from: win.from, to: win.to };
|
return { ref, from: win.from, to: win.to };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
|
||||||
|
// either side, with a spacer standing in for the rest so the scrollbar still spans
|
||||||
|
// the shelf. The wall holds every frame the open node does — a filter is a way of
|
||||||
|
// looking, not a second shelf — and a card is a card, an `<img>` 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<HTMLDivElement>; from: number; to: number; above: number; below: number } {
|
||||||
|
const ref = useRef<HTMLDivElement>(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<HTMLElement>(':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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
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. */
|
scrollbar would measure the window instead of the shelf. */
|
||||||
.adm-thumb-gap { flex: 0 0 auto; }
|
.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 {
|
.adm-thumb {
|
||||||
flex: 0 0 132px;
|
flex: 0 0 132px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user