fix(library): measure the wall's shelf in two steps so the scroll reaches its end
This commit is contained in:
+109
-70
@@ -360,7 +360,7 @@ export function Library() {
|
||||
}
|
||||
|
||||
import { useIsMobile } from './ui/useIsMobile';
|
||||
import { dayLabel, dayRuns } from './ui/dateGroups';
|
||||
import { bandRows, dayAt, dayKey, dayLabel, dayRuns, photoRow, rowAt, rowPhoto, rowTop, wallLayout, type WallSteps } from './ui/dateGroups';
|
||||
|
||||
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
const { t, lang } = useI18n();
|
||||
@@ -2028,7 +2028,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
// 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');
|
||||
// Where each day of the shelf begins, cut by the same rule the bands are drawn
|
||||
// by (`dayRuns`): the wall stands in rows for the days it is not drawing, so it
|
||||
// is the shelf's own days it counts and not the window's.
|
||||
const dayStarts = useMemo(() => {
|
||||
const starts: number[] = [];
|
||||
const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
|
||||
for (let i = 0; i < listed.length; i++) {
|
||||
if (!i || dayKey(at(listed[i])) !== dayKey(at(listed[i - 1]))) starts.push(i);
|
||||
}
|
||||
return starts;
|
||||
}, [listed]);
|
||||
const wall = useWallWindow(listed.length, dayStarts, view === 'grid');
|
||||
// Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
|
||||
// effect on the array it is handed, so a fresh slice every render made that
|
||||
// effect run every render — a `setUrls` per pass, a render per `setUrls`, and
|
||||
@@ -2144,12 +2155,27 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
|
||||
} else {
|
||||
// No card drawn: the wall opens on the first screenful, so aim at the row
|
||||
// and let the next pass find it. The row's step is read off a drawn card
|
||||
// when there is one, and guessed at when there is not.
|
||||
// the frame stands in and let the next pass find it. The shelf's own steps
|
||||
// and the rows its days are counted in are the wall's, read off the same
|
||||
// layout its window is measured against — a step of this effect's own is a
|
||||
// second answer to a question the wall has already answered.
|
||||
const first = el.querySelector<HTMLElement>('.adm-card');
|
||||
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
||||
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
|
||||
el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2);
|
||||
const steps = wall.steps.current;
|
||||
if (steps.row > 0) {
|
||||
const layout = wallLayout(dayStarts, listed.length, cols, steps);
|
||||
const row = photoRow(dayStarts, cols, layout.order, idx);
|
||||
el.scrollTop = Math.max(0, Math.round(rowTop(layout, row) - (el.clientHeight - steps.row) / 2));
|
||||
} else {
|
||||
// A step guessed at, for the one pass before the wall has drawn a card
|
||||
// to measure: the row the frame stands in is counted over the shelf's own
|
||||
// bands, not over its own index — every day spends a row above its first
|
||||
// frame.
|
||||
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
|
||||
const order = bandRows(dayStarts, listed.length, cols);
|
||||
const row = photoRow(dayStarts, cols, order, idx);
|
||||
el.scrollTop = Math.max(0, Math.round(row * pitch - (el.clientHeight - pitch) / 2));
|
||||
}
|
||||
}
|
||||
if (--left > 0) requestAnimationFrame(settle);
|
||||
else centredRef.current = null;
|
||||
@@ -2161,7 +2187,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
// `up` is in the list for the frame the studio hands back: it is the one that
|
||||
// moves when the shelf comes up on another frame, and a wall standing where it
|
||||
// already was is a wall that would not follow it.
|
||||
}, [view, listed, wall.ref, up]);
|
||||
}, [view, listed, dayStarts, wall.ref, up]);
|
||||
|
||||
// This screen's own comings and goings: it never unmounts, so `here` is the
|
||||
// only sign of the shelf being the screen up again (see `here` above).
|
||||
@@ -4079,7 +4105,21 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
|
||||
// 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 } {
|
||||
function useWallWindow(
|
||||
count: number,
|
||||
dayStarts: number[],
|
||||
on: boolean
|
||||
): {
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
from: number;
|
||||
to: number;
|
||||
above: number;
|
||||
below: number;
|
||||
// The steps the shelf is stacked in, which the wall reads off the cards it has
|
||||
// drawn and keeps. Handed back as the ref itself, not its value: the settle
|
||||
// effect aims a scroll with them on the pass after the one that measured them.
|
||||
steps: { current: WallSteps };
|
||||
} {
|
||||
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 }));
|
||||
@@ -4091,11 +4131,12 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
|
||||
// stands at.
|
||||
const winRef = useRef(win);
|
||||
const measureRef = useRef<() => void>(() => {});
|
||||
// The step from one row to the next, taken once per column count and kept. It
|
||||
// is a ref rather than a value read on every pass because the pass reads it off
|
||||
// the rows the window drew — and a step that moves with the window is a window
|
||||
// that moves with the step: the two trade places forever (see `measure`).
|
||||
const pitchRef = useRef(0);
|
||||
// The two steps the shelf is stacked in, taken once per column count and kept.
|
||||
// They are refs rather than values read on every pass because the pass reads
|
||||
// them off the rows the window drew — and a step that moves with the window is
|
||||
// a window that moves with the step: the two trade places forever (see
|
||||
// `measure`).
|
||||
const stepsRef = useRef<WallSteps>({ band: 0, row: 0, gap: WALL_GAP });
|
||||
const colsRef = useRef(0);
|
||||
// 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
|
||||
@@ -4157,64 +4198,62 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
|
||||
// run is a count the window feeds itself with. This is the same read the
|
||||
// settle effect above scrolls the wall by.
|
||||
const cols = Math.max(1, getComputedStyle(now).gridTemplateColumns.split(' ').length);
|
||||
// The run the step below is read off. A wall that has drawn nothing is
|
||||
// nothing to read — the state's own first screenful stands — but a run of a
|
||||
// single row is not nothing: it is the last row of a shelf scrolled to its
|
||||
// end, or the whole wall of a shelf of four frames. It has no average, and
|
||||
// the step it gets instead is its own height (below).
|
||||
// The cards are the day sections' own now: the header above a run is not a
|
||||
// card, and a row count taken with it would be a row count that drifts.
|
||||
const cards = now.querySelectorAll<HTMLElement>('.adm-card');
|
||||
const rowsDrawn = Math.floor(cards.length / cols);
|
||||
// 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.
|
||||
// The step is read off the drawn rows the first time the wall stands at this
|
||||
// many columns, and kept until it stands at another: the average of a run of
|
||||
// rows moves as the run grows, so a step re-read on every pass made the window
|
||||
// depend on itself — `to` traded places with a second sum a row away, sixty
|
||||
// times a second, until React gave up with #185 and the screen went blank. One
|
||||
// step, one window. It is re-read when the wall re-columns, the only thing that
|
||||
// The rows the window drew, counted the way the wall lays them out: a day's
|
||||
// band is a row of its own (`grid-column: 1 / -1`) and its frames fill the
|
||||
// rows under it. A row count taken off the cards alone is a count that
|
||||
// leaves out every band — and the bands are what the rows are counted in.
|
||||
const sections = [...now.querySelectorAll<HTMLElement>('.adm-day')];
|
||||
// The two steps the shelf is stacked in, read off the drawn days: the band's
|
||||
// own height, from a day's own box less the rows of frames it stands over,
|
||||
// and the step from one row of frames to the next, which is a card and the
|
||||
// grid's own gap. A band is a line of words and a row of frames is a
|
||||
// picture: one step taken as their average is a step that is wrong for both,
|
||||
// and the spacers standing in for the rows not drawn come out short or long
|
||||
// by however much of the shelf is bands — which moves as the window does.
|
||||
// Read the first time the wall stands at this many columns, and kept until
|
||||
// it stands at another: a step re-read on every pass made the window depend
|
||||
// on itself — `to` traded places with a second sum a row away, sixty times a
|
||||
// second, until React gave up with #185 and the screen went blank. One step,
|
||||
// one window. It is re-read when the wall re-columns, the only thing that
|
||||
// changes a card's own height (a caption wraps at the width it is given); a
|
||||
// resize that does not re-column keeps the step, and a spacer a few pixels out
|
||||
// is a scrollbar a few pixels out, not a blank screen.
|
||||
if (colsRef.current !== cols && rowsDrawn >= 2) {
|
||||
const drawn = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1);
|
||||
if (drawn > 0) {
|
||||
colsRef.current = cols;
|
||||
pitchRef.current = drawn;
|
||||
// resize that does not re-column keeps the step, and a spacer a few pixels
|
||||
// out is a scrollbar a few pixels out, not a blank screen.
|
||||
if (colsRef.current !== cols) {
|
||||
const first = sections[0];
|
||||
const card = first?.querySelector<HTMLElement>('.adm-card');
|
||||
if (first && card) {
|
||||
const row = card.offsetHeight + WALL_GAP;
|
||||
const band = first.getBoundingClientRect().height - Math.ceil(first.querySelectorAll('.adm-card').length / cols) * row;
|
||||
if (row > 0 && band > 0) {
|
||||
colsRef.current = cols;
|
||||
stepsRef.current = { band, row, gap: WALL_GAP };
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the one-row run, which has no average to take: its own height is the
|
||||
// step — one card plus the gap, the same fallback the settle effect takes.
|
||||
// Returning here instead is a window that cannot grow, and there is a shelf
|
||||
// it strands: the reset above puts a year of four frames at {0, 4}, one row
|
||||
// of five columns, no average — and every pass after it reads that one row
|
||||
// and leaves, so the shelf stands at four cards for the rest of the visit;
|
||||
// another year, another folder, a reset, all four cards under a count of six
|
||||
// hundred. The step is a guess, and a spacer a few pixels out is a scrollbar
|
||||
// a few pixels out; a window left alone is the wall.
|
||||
let pitch = pitchRef.current;
|
||||
if (!(pitch > 0) || colsRef.current !== cols) {
|
||||
const first = cards[0];
|
||||
if (!first) return;
|
||||
pitch = first.offsetHeight + WALL_GAP;
|
||||
if (!(pitch > 0)) return;
|
||||
colsRef.current = cols;
|
||||
pitchRef.current = pitch;
|
||||
}
|
||||
const rows = Math.ceil(count / cols);
|
||||
const row0 = Math.max(0, Math.floor(now.scrollTop / pitch) - WALL_ROWS);
|
||||
const row1 = Math.min(rows, Math.ceil((now.scrollTop + now.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;
|
||||
// And a run with no steps to read — a shelf of one day whose only card is
|
||||
// not in the DOM yet, a wall of four frames — has none to measure with. It
|
||||
// is left to the pass after this one, which has the cards: returning here
|
||||
// instead is a window that cannot grow, and there is a shelf it strands —
|
||||
// the reset above puts a year of four frames at {0, 4}, and every pass after
|
||||
// it reads that one row and leaves, so the shelf stands at four cards for
|
||||
// the rest of the visit.
|
||||
const steps = stepsRef.current;
|
||||
if (!(steps.band > 0 && steps.row > 0) || !dayStarts.length) return;
|
||||
// The shelf measured in those steps: the two spacers, the rows drawn, and
|
||||
// the scrollbar between them are all read off the same layout, so the
|
||||
// shelf's own height does not move with the window.
|
||||
const layout = wallLayout(dayStarts, count, cols, steps);
|
||||
const row0 = Math.max(0, rowAt(layout, now.scrollTop) - WALL_ROWS);
|
||||
const row1 = Math.min(layout.rows, rowAt(layout, now.scrollTop + now.clientHeight) + WALL_ROWS);
|
||||
const from = rowPhoto(dayStarts, count, cols, layout.order, row0);
|
||||
const to = Math.max(from, rowPhoto(dayStarts, count, cols, layout.order, row1));
|
||||
// What the two spacers stand for: the days above the window, and the days
|
||||
// under it. A spacer's own height keeps the gap the grid would have left
|
||||
// under the last day it stands in for, which is why the gap comes off both.
|
||||
const first = dayAt(dayStarts, from);
|
||||
const last = dayAt(dayStarts, Math.max(from, to - 1));
|
||||
const above = first ? Math.max(0, layout.tops[first] - WALL_GAP) : 0;
|
||||
const below = Math.max(0, layout.height - layout.ends[last] - WALL_GAP);
|
||||
show({ from, to, above, below });
|
||||
};
|
||||
measureRef.current = measure;
|
||||
@@ -4230,9 +4269,9 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
|
||||
el.removeEventListener('scroll', onOutside);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [count, on]);
|
||||
}, [count, dayStarts, on]);
|
||||
useLayoutEffect(() => {
|
||||
measureRef.current();
|
||||
});
|
||||
return { ref, from: win.from, to: win.to, above: win.above, below: win.below };
|
||||
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user