fix(library): draw the wall whole and read its time down the right edge

This commit is contained in:
2026-10-13 09:38:05 +07:00
parent d786b84eb1
commit 62f244f3c2
7 changed files with 509 additions and 706 deletions
+229 -61
View File
@@ -1,17 +1,18 @@
// The wall, checked where it can actually go wrong: in a browser, on a shelf long
// enough that the wall is drawing a few of its rows and standing in for the rest.
// enough that a day of it is a screen and the shelf is many screens of them.
//
// One failure is what this guards, and it is the one that has already cost a
// white screen: a window written from the window before it. The wall measures
// itself after every render, writes what it measured, and renders again — so an
// input the measure takes off the window it just wrote feeds itself, pass after
// pass, until React gives up with #185, unmounts the tree, and leaves the reader
// with nothing (there is no error boundary under it).
// What it guards is the wall *being* the shelf, and saying so: every frame the open
// node holds is a card (the wall is drawn whole — no window over it, so no card
// arriving late and no spacer standing in for one), every card sits under the band
// of its own day, the last frame is reachable by scrolling with nothing left under
// it, and the rail down the wall's right edge — the shelf's own time — reads the
// day the eye is in and takes the wall to the day it is pressed for.
//
// The trigger is not spelled out — no browser reproduces the reader's own,
// which is the point — so the check drives the *class*: the wall's own box
// reported at two different heights on every pass, plus one scroll to start it.
// That is what any read-back is, and the wall must survive it.
// It is drawn whole for a reason this check keeps an eye on: the wall used to be a
// window over the shelf, and a window measured off the window before it cost a
// white screen once (React #185, no error boundary under it). None of that state is
// left — but a resize under the wall's own eye is the same class of input, and the
// wall re-columns and the rail re-reads through it without taking the screen down.
//
// npm run build && node scripts/wall-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/wall-check.mjs
@@ -30,6 +31,12 @@ const APP_PORT = Number(process.env.APP_PORT ?? 5033);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
const FRAMES = Number(process.env.FRAMES ?? 300);
const FOLDER = 'WallRoll';
// The second roll: a shelf of one month, which the rail reads by the day rather
// than by the month — the other half of what the rail is (`rail` in Library.tsx),
// and the shape a folder per event has. Its frames are two days of twenty.
const SMALL_FOLDER = 'OneDayRoll';
const SMALL_EACH = 20;
const SMALL_DAYS = 2;
// Every frame is the same bytes under a different name: what is being checked is
// how many cards the wall will draw, not what is on them. The bytes carry no
// EXIF, so a frame's date is its own `lastModified` — spread over the days below,
@@ -68,6 +75,42 @@ const dayIndex = (n) => {
};
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS;
// The second roll's own names and days: `one-0001` onwards, twenty to a day, over
// the fourth and fifth of March 2026 — one month, two days, which is the shelf the
// rail reads by the day.
const smallNames = Array.from(
{ length: SMALL_DAYS * SMALL_EACH },
(_, i) => `one-${String(i + 1).padStart(4, '0')}.jpg`
);
const smallDayOfName = (name) =>
Date.UTC(2026, 2, 4 + Math.floor((Number((name.match(/\d+/) ?? ['1'])[0]) - 1) / SMALL_EACH));
const allNames = [...names, ...smallNames];
// What the tree is asked for and the sample server serves: both rolls.
const servedNames = new Set(allNames);
// Which of the two rolls a name belongs to: the big one is the shelf this check
// walks, the small one is a shelf of a single month.
const dayOf = (name) => (smallNames.includes(name) ? smallDayOfName(name) : dayOfName(name));
// The months of the big roll, and the days of the small one: what the rail has to
// hold, read off the roll rather than written down twice.
const monthOf = (ts) => {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
};
const keyOf = (ts) => {
const d = new Date(ts);
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`;
};
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
const SMALL_DAY_KEYS = [...new Set(smallNames.map((name) => keyOf(dayOf(name))))];
// The days the big roll is cut into, which is what the wall draws bands for.
const BIG_DAYS = (() => {
let day = 0;
for (let i = 0; i < FRAMES; i += 1) {
if (!i || dayOfName(names[i]) !== dayOfName(names[i - 1])) day += 1;
}
return day;
})();
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
@@ -85,7 +128,7 @@ if (!playwright) {
const jpg = await readFile(SAMPLE_FILE);
const sampleServer = createServer((req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!names.includes(name)) {
if (!servedNames.has(name)) {
res.writeHead(404).end();
return;
}
@@ -159,10 +202,13 @@ await context.route('**/api/**', (route) => {
// they are put back where the catalogue reads them, because a cloned handle is a
// plain object with no `getFile`.
await context.addInitScript(
({ origin, names, folder, days }) => {
({ origin, names, folder, days, smallNames, smallFolder, smallDays }) => {
// `days` is the day of each frame, index-aligned with `names`: the init
// script is cloned into the page and cannot close over the check's own scope.
const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1);
const dayOfNameIn = (name) => {
const at = names.indexOf(name);
return at >= 0 ? days[at] : smallDays[smallNames.indexOf(name)] ?? Date.UTC(2026, 0, 1);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
@@ -190,15 +236,21 @@ await context.addInitScript(
});
return handle;
};
const root = () => dirHandle(folder, { files: names });
window.showDirectoryPicker = async () => root();
// The folder the reader picks: the first press is the shelf this check walks,
// the second is the shelf of a single month (the rail's other reading). A
// picker is one folder at a time, and a check that needs two picks is a check
// that hands them over one after the other.
const root = (label) =>
label === smallFolder ? dirHandle(smallFolder, { files: smallNames }) : dirHandle(folder, { files: names });
let picked = 0;
window.showDirectoryPicker = async () => root(picked++ === 0 ? folder : smallFolder);
// The day each frame was given, for the header audit below: the page's own
// clock reads the label, so the day is read back here the same way.
window.__dayOf = (name) => dayOfNameIn(name);
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
row.handle = row.handle.kind === 'directory' ? root(row.handle.name) : fileHandle(row.handle.name);
}
return row;
};
@@ -218,7 +270,15 @@ await context.addInitScript(
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) }
{
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
names,
folder: FOLDER,
days: names.map(dayOfName),
smallNames,
smallFolder: SMALL_FOLDER,
smallDays: smallNames.map(smallDayOfName),
}
);
const page = await context.newPage();
@@ -275,31 +335,62 @@ const dayAudit = () =>
return out;
});
// --- the wall, drawn whole ---------------------------------------------------
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
// The wall is the shelf: what the open node holds is what has to be on it. A window
// over the shelf is a shelf that tears itself open under the reader — a day drawn a
// couple of rows deep, growing as the eye walks down it, and a frame scrolled back
// up to built again — so this is the first thing read.
await page.waitForFunction(
(n) => document.querySelectorAll('.adm-grid .adm-card').length >= n,
FRAMES,
{ timeout: 120_000, polling: 250 }
);
await page.waitForTimeout(2000);
const wall = await read();
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
step('the wall draws every frame of the roll', wall.cards === FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
step('and it is a shelf that scrolls', wall.wall, 'the wall scrolls');
const days1 = await dayAudit();
// The first screenful is one day of a mixed roll often as not — a day big enough
// to fill it is a day at all — so what is read here is that a band stands over
// the cards and names its day, and the cut into several days is read below, on a
// wall scrolled off the fold.
step('the wall draws its days over the cards', days1.sections >= 1 && days1.labelled === days1.sections, `${days1.sections} headers over ${days1.cards} cards`);
step('the wall draws a band for every day of the roll', days1.sections === BIG_DAYS, `${days1.sections} days of ${BIG_DAYS}`);
step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2)));
// The same wall, scrolled to where no day starts at the fold: the headers have to
// move with it, which is the whole risk of cutting a virtualised wall into days.
// Read down the shelf rather than at one stop: a stop of any one fraction lands
// inside a day big enough to fill the screen as often as it lands on a fold, so
// what is read is that no band ever files a card under another day's header, and
// that the shelf's own cuts move through the window as it travels.
// What the rail is read for: the entries it holds, the entry the eye is in, and where
// a press on one takes the wall. The day at the fold is read off the sections
// themselves — the first one crossing the wall's top band, which is the rule the rail
// itself reads by — and the entry the wall says it is in is read off the accent.
const railOf = () =>
page.evaluate(() => {
const grid = document.querySelector('.adm-grid');
const rail = document.querySelector('.lib-rail');
const items = [...(rail?.querySelectorAll('.lib-rail-item') ?? [])];
const top = grid.getBoundingClientRect().top;
const band = top + grid.clientHeight * 0.12;
let fold = null;
for (const section of grid.querySelectorAll('.adm-day')) {
const box = section.getBoundingClientRect();
if (box.top <= band && box.bottom > top) {
fold = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
break;
}
}
const on = document.querySelector('.lib-rail-item.on');
return {
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
labels: items.map((b) => b.textContent.trim()),
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
fold,
scroll: Math.round(rail?.scrollTop ?? 0),
tops: items.map((b) => b.offsetTop),
heights: items.map((b) => b.offsetHeight),
box: rail?.clientHeight ?? 0,
};
});
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenDays = new Set();
const seenMonths = new Set();
let scrolledBad = [];
let railBad = [];
for (const at of stops) {
await page.evaluate((f) => {
const el = document.querySelector('.adm-grid');
@@ -308,22 +399,59 @@ for (const at of stops) {
}, at);
await page.waitForTimeout(900);
const audit = await dayAudit();
for (const section of await page.$$eval('.adm-day', (els) => els.map((e) => e.getAttribute('data-key')))) seenDays.add(section);
scrolledBad = scrolledBad.concat(audit.bad);
const rail = await railOf();
// The entry the rail says the eye is in has to own the day at the fold — the month
// its key begins with — and the entry has to be inside the rail's own box: a rail
// read for the whole shelf and standing on its own top is a rail that says nothing.
if (!rail.fold || !rail.on || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
railBad.push({ at, fold: rail.fold, on: rail.on });
} else {
seenMonths.add(rail.on.slice(0, 7));
const i = rail.keys.indexOf(rail.on);
const y = (rail.tops[i] ?? 0) - rail.scroll;
if (y < -2 || y + (rail.heights[i] ?? 0) > rail.box + 2) railBad.push({ at, on: rail.on, y, box: rail.box });
}
}
step(
'a scrolled wall still files every card under its own day',
scrolledBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
scrolledBad.length === 0,
JSON.stringify(scrolledBad.slice(0, 2))
);
const rail1 = await railOf();
const railMonths = [...new Set(rail1.keys.map((k) => k.slice(0, 7)))].sort();
step('the rail holds the months of the shelf', railMonths.join() === [...MONTHS].sort().join(), `${rail1.keys.join(' ')}`);
step(
'and the entry it reads follows the eye down the shelf',
railBad.length === 0 && seenMonths.size === MONTHS.length,
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
);
// The shelf walked to its own end, a screen at a time — a jump straight to the
// bottom is a different question, and the one this check exists for: the wall
// stands in for the frames it is not drawing, and a spacer standing in for more
// rows than those frames take leaves the reader at the end of the scroll with a
// screen of nothing under the last card drawn and the rest of the roll never
// drawn at all. What the walk is read for is that it *gets* there, standing on
// the last frame, and that every frame of the roll was drawn on the way.
// And the press: the month's own first day is where the wall has to stand, its header
// just under the wall's top edge, and the entry pressed is the one read afterwards.
const jumped = rail1.keys[1];
await page.click(`[data-key="lib-rail-${jumped}"]`);
await page.waitForTimeout(700);
const landed = await page.evaluate((key) => {
const grid = document.querySelector('.adm-grid');
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
const box = grid.getBoundingClientRect();
const on = document.querySelector('.lib-rail-item.on');
return {
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
};
}, jumped);
step(
'a press on a month takes the wall to that day',
landed.top !== null && Math.abs(landed.top - 6) <= 6 && landed.on === jumped,
`header at ${landed.top}, entry ${landed.on}`
);
// The shelf walked to its own end, a screen at a time — a jump straight to the bottom
// is a different question, and this is the one that stops a reader at a screen of
// nothing: what the walk is read for is that it *gets* there, standing on the last
// frame with no room left under it.
const walked = await page.evaluate(async () => {
const el = document.querySelector('.adm-grid');
const seen = new Set();
@@ -348,35 +476,75 @@ const walked = await page.evaluate(async () => {
top: Math.round(el.scrollTop),
reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2,
room: Math.round(el.scrollHeight - el.clientHeight),
// What is left under the last card drawn: the spacer that has nothing left
// to stand in for. A few pixels is the grid's own gap.
// What is left under the last card drawn. A few pixels is the grid's own gap.
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : null,
seen: [...seen],
};
});
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`);
step('and stands on nothing there', walked.gap !== null && walked.gap <= 16, `${walked.gap}px of nothing under the last card`);
const missed = names.filter((name) => !walked.seen.includes(`lib-photo-${FOLDER}/${name}`));
step('every frame of the roll is drawn on the way', missed.length === 0, `${walked.seen.length} of ${names.length} drawn, first missed ${missed[0] ?? 'none'}`);
// The class of trigger, made to happen: one input the wall reads back off itself,
// moving on every pass.
await page.evaluate(() => {
const el = document.querySelector('.adm-grid');
const h = el.clientHeight;
let n = 0;
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) });
el.dispatchEvent(new Event('scroll'));
});
await page.waitForTimeout(8000);
// A wall resized under its own eye: the class of input that cost the screen once, when
// the wall measured itself off itself. It re-columns, the rail re-reads, and the reader
// is still standing in the library after it.
await page.setViewportSize({ width: 1180, height: 820 });
await page.waitForTimeout(700);
await page.setViewportSize({ width: 1500, height: 950 });
await page.waitForTimeout(700);
const after = await read();
const looped = errors.filter((e) => e.includes('185') || e.includes('update depth'));
step(
'a wall fed its own window does not take the screen down',
!after.blank && after.cards > 0 && looped.length === 0,
'a wall resized under its own eye does not take the screen down',
!after.blank && after.cards === FRAMES && looped.length === 0,
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
);
step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
const rail2 = await railOf();
step(
'and its rail still reads the day at the fold',
rail2.on !== null && rail2.fold !== null && rail2.fold.slice(0, 7) === rail2.on.slice(0, 7),
`fold ${rail2.fold}, entry ${rail2.on}`
);
// The rail's other reading: a shelf of a single month is a shelf read by the day. The
// second folder picked is that shelf — two days of twenty frames — and the wall on it
// is a wall of two bands.
await page.click('[data-key="lib-add"]');
await page.waitForSelector(`[data-key="lib-node-${SMALL_FOLDER}"]`, { timeout: 60_000 });
await page.click(`[data-key="lib-node-${SMALL_FOLDER}"]`);
await page.waitForFunction(
(n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)),
smallNames.length,
{ timeout: 240_000, polling: 250 }
);
await page.click('[data-key="lib-view-grid"]');
await page.waitForTimeout(1500);
const rail3 = await railOf();
step(
'a shelf of one month is read by the day',
[...rail3.keys].sort().join() === [...SMALL_DAY_KEYS].sort().join() && rail3.keys.length === SMALL_DAY_KEYS.length,
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
);
const plainDay = SMALL_DAY_KEYS[0];
await page.click(`[data-key="lib-rail-${plainDay}"]`);
await page.waitForTimeout(700);
const small = await page.evaluate((key) => {
const grid = document.querySelector('.adm-grid');
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
const box = grid.getBoundingClientRect();
const on = document.querySelector('.lib-rail-item.on');
return {
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
cards: section ? section.querySelectorAll('.adm-card').length : 0,
on: on?.textContent.trim() ?? null,
};
}, plainDay);
step(
'a press on a day takes the wall to it, whole',
small.top !== null &&
Math.abs(small.top - 6) <= 6 &&
small.cards === SMALL_EACH &&
small.on === String(Number(plainDay.slice(8, 10))),
`header at ${small.top}, ${small.cards} cards, entry ${small.on}`
);
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
-146
View File
@@ -1,146 +0,0 @@
// The wall's own model of its rows (src/ui/dateGroups.ts, `bandRows` /
// `rowPhoto` / `photoRow`), checked where it can go wrong without a browser.
//
// The wall draws the rows around the reader and a spacer stands in for the rest,
// so a model of the rows that is out of step with the shelf drawn is a reader
// stopping at a screen of nothing with frames below them never drawn — which is
// what a flat `count / cols` count of rows is the moment the shelf is cut into
// days: every day spends a row on its band, and a day whose frames do not come
// out even over the columns leaves the rest of its last row empty.
//
// What has to hold is that the rows tile the shelf: one window per row, in
// order, meeting at both ends and never leaving a frame between two of them, and
// that a frame stands in the row the browser will draw it in.
//
// node scripts/wall-slot-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const src = readFileSync(new URL('../src/ui/dateGroups.ts', import.meta.url), 'utf8');
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const { bandRows, dayAt, rowPhoto, photoRow, rowAt, rowTop, wallLayout } = await import(
`data:text/javascript,${encodeURIComponent(js)}`
);
// The frames per day, cycling — and with them the shelves the check stands on: a
// shelf of equal days, a shelf of single-frame days, one day of everything, and
// the rolls in between, which is what a camera roll is and where a flat count of
// rows stops being right.
const SHELVES = [
[300, [10]],
[200, [1]],
[120, [120]],
[300, [1, 40, 2, 3, 60, 1, 8, 120, 4, 2, 25, 7]],
[1, [1]],
[7, [2, 3]],
[0, []],
];
const COLUMNS = [1, 2, 3, 6, 7, 13];
// The two steps a shelf is stacked in, in the two shapes a measurement of them can
// come out: a band of words a good deal shorter than a row of pictures, which is
// the shelf on screen, and the two equal, which is what the model looked like when
// it was one step and has to keep working.
const STEPS = [
{ band: 44, row: 208, gap: 12 },
{ band: 208, row: 208, gap: 12 },
];
let checked = 0;
for (const [count, pattern] of SHELVES) {
// The first frame of every day, off the pattern — the same list the wall cuts
// its own `dayStarts` into.
const days = [];
for (let i = 0, day = 0; i < count; day++) {
days.push(i);
i += pattern[day % pattern.length];
}
assert.equal(days[0] ?? 0, 0);
for (const cols of COLUMNS) {
const order = bandRows(days, count, cols);
const rows = order[days.length];
// One band per day, and its frames under it in rows of `cols`: the rows the
// shelf is drawn in, which is what the model has to come to.
let drawn = 0;
for (let k = 0; k < days.length; k++) {
drawn += 1 + Math.ceil(((days[k + 1] ?? count) - days[k]) / cols);
}
assert.equal(rows, drawn, `${count} frames in ${days.length} days at ${cols} columns: ${rows} rows modelled, ${drawn} drawn`);
// The rows tile the shelf: row `r` draws from `rowPhoto(r)` and the next row
// takes it up from there, the first row starts at the first frame, and the
// row past the last answers with the end of the shelf.
let from = rowPhoto(days, count, cols, order, 0);
assert.equal(from, 0, `${count}/${cols}: the shelf starts at its first frame`);
for (let r = 1; r <= rows; r++) {
const to = rowPhoto(days, count, cols, order, r);
assert.ok(to >= from, `${count}/${cols}: row ${r} answers ${to}, behind ${from}`);
assert.ok(to <= count, `${count}/${cols}: row ${r} answers ${to} of ${count}`);
if (r === rows) assert.equal(to, count, `${count}/${cols}: the last row reaches the end`);
from = to;
}
// And a frame's own row is the row it is drawn in: that row's window starts
// at or above it, and the row after starts below it.
for (let i = 0; i < count; i++) {
const row = photoRow(days, cols, order, i);
assert.ok(row >= 1 && row < rows, `${count}/${cols}: frame ${i} is drawn in row ${row} of ${rows}`);
assert.ok(rowPhoto(days, count, cols, order, row) <= i, `${count}/${cols}: frame ${i} stands past the start of row ${row}`);
assert.ok(rowPhoto(days, count, cols, order, row + 1) > i, `${count}/${cols}: frame ${i} stands past the end of row ${row}`);
checked++;
}
// The shelf's own height and the two spacers that stand in for the days not
// drawn, in the two steps it is actually stacked in. What has to hold is that
// the shelf comes out the same height whichever window is over it: a height
// that moves with the window is the reader walking down to the end of the
// scroll and stopping a screen short of the frames it was standing in for.
for (const steps of STEPS) {
const at = `${count} frames in ${days.length} days at ${cols} columns, band ${steps.band}/row ${steps.row}`;
const layout = wallLayout(days, count, cols, steps);
const heights = days.map(
(start, k) => steps.band + Math.ceil(((days[k + 1] ?? count) - start) / cols) * steps.row
);
const drawnHeight = heights.reduce((sum, h) => sum + h, 0);
assert.equal(layout.height, days.length ? drawnHeight + (days.length - 1) * steps.gap : 0, `${at}: the shelf is ${layout.height} tall`);
assert.equal(layout.rows, rows, `${at}: ${layout.rows} rows of the two steps, ${rows} drawn`);
if (!days.length) continue;
assert.equal(layout.ends[days.length - 1], layout.height, `${at}: the last day ends at the shelf's own height`);
// A row stands below the row before it, and the top it stands at reads back
// as the row it is — which is what puts a scroll where a frame is.
let last = -1;
for (let r = 0; r < layout.rows; r++) {
const top = rowTop(layout, r);
assert.ok(top > last, `${at}: row ${r} stands at ${top}, behind ${last}`);
last = top;
assert.equal(rowAt(layout, top), r, `${at}: row ${r} at ${top} reads back as ${rowAt(layout, top)}`);
}
assert.equal(rowTop(layout, layout.rows - 1) + steps.row, layout.height, `${at}: the last row reaches the shelf's own height`);
assert.equal(dayAt(days, count - 1), days.length - 1, `${at}: the last frame is filed under the last day`);
// And every window of days lays out the same shelf: the spacer over it, the
// days drawn, the spacer under it and the grid's own gaps add up to one
// height, whichever days are drawn.
for (let a = 0; a < days.length; a++) {
for (let b = a; b < days.length; b++) {
// Laid out the way the DOM lays it: a spacer where the wall draws one
// (a height of nought is no spacer), the days drawn, the grid's gap
// between one item and the next.
const above = a ? Math.max(0, layout.tops[a] - steps.gap) : 0;
const below = Math.max(0, layout.height - layout.ends[b] - steps.gap);
const items = b - a + 1 + (above > 0 ? 1 : 0) + (below > 0 ? 1 : 0);
let sum = 0;
for (let k = a; k <= b; k++) sum += heights[k];
const total = above + sum + below + (items - 1) * steps.gap;
assert.equal(total, layout.height, `${at}: days ${a}..${b} stand ${total} tall, the shelf ${layout.height}`);
}
}
checked++;
}
}
}
console.log(`ok ${checked} frames placed on ${SHELVES.length} shelves`);
+226 -341
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api, type User } from './api';
@@ -207,35 +207,20 @@ 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;
// How many tiles the wall reads and decodes at once when it is the one asking.
// The wall's own frames come first and they come visibly: two at a time, the
// thread handed back between them (`breathe`), is a shelf that fills in front of
// the reader — a screenful of frames read end to end is a dead screen.
const WALL_LANES = 2;
// A folder is walked into with a screenful in sight, not the whole roll: the tiles
// asked for when a folder opens are the ones about to be drawn, and the wall mints
// the rest as it scrolls. A job over every frame of the roll is a decode of every
// frame of the roll — half an hour of disk for a folder nobody has scrolled past
// the first screen of.
// asked for when a folder opens are the ones about to be drawn. A job over every
// frame of the roll is a decode of every frame of the roll — half an hour of disk
// for a folder nobody has scrolled past the first screen of.
// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main
// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one
// folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at. The wall mints the rest as it scrolls, which is
// who the queue was for.
// the one they are looking at.
const THUMB_AHEAD = 60;
// 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;
// How many windows the wall may write in a row before it is talking to itself.
// A window read off the window before it is a loop, and React answers the fifty
// turns of one with #185 and a blank screen; four is more than an honest change
// needs — a re-column takes two passes — and the count is handed back by the
// first scroll, resize or change of shelf the reader makes.
const WALL_CARRIES = 4;
// 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);
@@ -360,7 +345,13 @@ export function Library() {
}
import { useIsMobile } from './ui/useIsMobile';
import { bandRows, dayAt, dayKey, dayLabel, dayRuns, photoRow, rowAt, rowPhoto, rowTop, wallLayout, type WallSteps } from './ui/dateGroups';
import { dayLabel, dayRuns, monthKey, monthLabel } from './ui/dateGroups';
// One entry of the rail down the wall's right edge. `key` is what the entry
// stands for — a month (`2026-10`) or a day (`2026-10-04`) — and `at` is the day
// key the wall is sent to when it is pressed, which is the first day of the
// month for a month's entry.
type RailMark = { key: string; at: string; label: string; title: string };
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const { t, lang } = useI18n();
@@ -2022,39 +2013,117 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
});
}, []);
// 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.
// 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);
// The wall is the other list, and it is drawn whole. It used to be drawn the way
// the strip is — the rows around the eye, with a spacer standing in for the rest
// — and what the reader sees in that is a shelf tearing itself open: the frames
// below the fold are not in the DOM to be scrolled to, so a day is drawn a couple
// of rows deep and grows as the eye walks down it, a frame scrolled back up to is
// a frame the wall has to build again, and the shelf's own end is a spacer that
// has to be right to the pixel or the scroll stops in a screen of nothing.
//
// What makes it safe to draw whole is that the wall now holds no state: no
// measure, no spacer, no model of where a row stands — the browser lays the grid
// out once and scrolls it, and there is no second answer to be at odds with the
// shelf drawn. `loading="lazy"` on the tiles (see `TileImg`) is what keeps the
// frames below the fold from being read before the eye is near them, and the
// tiles behind them are minted off the whole list, a couple of lanes at a time
// (`useThumbUrls`), so a day scrolled to has its pictures rather than a rack of
// empty cards.
//
// ponytail: the DOM is the whole shelf — twenty thousand frames is twenty
// thousand cards built up front, which is seconds of dead screen. When a roll
// that size is real, the wall goes back to a window, cut by the day rather than
// by the row: a day is a height the browser can be told in advance
// (`content-visibility` and the frames under it), which is what a window of rows
// could not be.
//
// The wall is read by the day a frame was shot, and the line above each run is
// that day. Cut off the list as it stands rather than sorted into days of its
// own, so the sort chip keeps the order it names.
const days = useMemo(() => dayRuns(listed, (photo) => photo.taken || photo.addedAt), [listed]);
// The wall's own box, and the rail is read off it: the scroll that reaches every
// frame is the grid's, since the grid is the whole shelf.
const gridRef = useRef<HTMLDivElement>(null);
// The rail down the wall's right edge: the shelf's time, one entry a month, or a
// day each when the shelf has not a month's worth of months. It is cut off the
// same runs the wall is drawn in, and an entry is looked up by the day key it
// stands for, so an entry and the header it jumps to are one cut of the shelf and
// not two.
const rail = useMemo(() => {
const months: RailMark[] = [];
const each: RailMark[] = [];
for (const day of days) {
const month = monthKey(day.at);
if (months[months.length - 1]?.key !== month) {
months.push({ key: month, at: day.key, label: monthLabel(day.at, lang), title: dayLabel(day.at, lang) });
}
each.push({ key: day.key, at: day.key, label: String(new Date(day.at).getDate()), title: dayLabel(day.at, lang) });
}
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
// thousands of passes a second in which the tiles of the rows scrolled to never
// land at all.
const painted = useMemo(
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
[listed, wall.from, wall.to]
);
// The same slice, cut into the days it holds: a wall is read by the day a frame
// was shot, and the line above each run is that day. Cut off the slice as it
// stands rather than sorted into days of its own, so the sort chip keeps the
// order it names; the first run may be a day that started above the window,
// which is its own header drawn where the eye lands (see `dayRuns`).
const paintedDays = useMemo(() => dayRuns(painted, (photo) => photo.taken || photo.addedAt), [painted]);
return months.length > 1 ? months : each;
}, [days, lang]);
const [railAt, setRailAt] = useState(0);
const railRef = useRef<HTMLElement>(null);
// Which entry the eye is on. The days are watched from the wall's own box and
// the one crossing its top band is the day being read; the entry that owns it is
// the month (or the day) its key begins with. Nothing is measured here and
// nothing is written back to the wall — the browser is the one that knows, and it
// says so. A flick can leave the band empty; the rail keeps the entry it had
// rather than blinking back to the top of the shelf.
useEffect(() => {
if (view !== 'grid') return;
const el = gridRef.current;
if (!el) return;
const nodes = [...el.querySelectorAll<HTMLElement>('.adm-day')];
if (!nodes.length) return;
const place = new Map(nodes.map((node, i) => [node.dataset.key ?? '', i]));
const seen = new Set<string>();
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const key = (entry.target as HTMLElement).dataset.key ?? '';
if (entry.isIntersecting) seen.add(key);
else seen.delete(key);
}
let best = -1;
for (const key of seen) {
const i = place.get(key) ?? -1;
if (i >= 0 && (best < 0 || i < best)) best = i;
}
if (best < 0) return;
const key = (nodes[best].dataset.key ?? '').replace('lib-day-', '');
const found = rail.findIndex((mark) => key.startsWith(mark.key));
setRailAt(found < 0 ? 0 : found);
},
{ root: el, rootMargin: '0px 0px -88% 0px' }
);
for (const node of nodes) io.observe(node);
return () => io.disconnect();
}, [rail, days, view]);
// The rail's own scroll: the entry being read is kept by the middle of the
// column, so a shelf of years is not a rail standing on its own top.
useEffect(() => {
const el = railRef.current;
const item = el?.children[railAt] as HTMLElement | undefined;
if (!el || !item) return;
const want = Math.max(0, item.offsetTop - el.clientHeight / 2 + item.offsetHeight / 2);
if (Math.abs(el.scrollTop - want) > 8) el.scrollTop = want;
}, [railAt]);
// A press on an entry takes the wall to the day it stands for: the header is at
// a place the grid already knows, and the scroll is put where it stands — a jump
// and not a step, and the highlight follows the eye from there like any other
// scroll.
const jumpToTime = (key: string) => {
const el = gridRef.current;
const day = el?.querySelector<HTMLElement>(`[data-key="lib-day-${CSS.escape(key)}"]`);
if (!el || !day) return;
const box = el.getBoundingClientRect();
el.scrollTop = Math.max(0, el.scrollTop + day.getBoundingClientRect().top - box.top - 6);
};
// A tile the studio graded while this screen was dark: the copy in hand is the
// negative's, and `onThumbWritten` hands over the one that replaces it. The
// counter is what makes the rows look again — the list is unchanged, so nothing
@@ -2073,7 +2142,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}),
[]
);
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
const urls = useThumbUrls(view === 'preview' ? drawn : listed, frame, tilesAt);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
@@ -2127,20 +2196,16 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}, [view, frame?.id, listed, node, strip.ref]);
// And the wall's half of it: the frame the strip was on, brought to the middle
// of the wall. The card is not in the DOM to begin with — the wall only paints
// the rows around its viewport — so the first passes go to where the row should
// be and the ones after that read the card itself, which is the only honest
// measure: a row stands as tall as its longest caption. The loop is bounded
// rather than driven by state, because the wall reports its window by
// re-rendering and a scroll set inside a render is a scroll the next render
// undoes.
// of the wall. The card is in the DOM — the wall draws the whole shelf — so this
// is one scroll of the grid and no model of where a row stands. The loop is for
// the pass the list has not landed on yet, when there is no card to measure and
// what has to run this again is the list itself, not a frame of waiting.
useEffect(() => {
if (view !== 'grid') return;
const id = centredRef.current;
const el = wall.ref.current;
const el = gridRef.current;
if (!id || !el) return;
const idx = listed.findIndex((p) => p.id === id);
if (idx < 0) {
if (!listed.some((p) => p.id === id)) {
centredRef.current = null;
return;
}
@@ -2153,29 +2218,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const box = el.getBoundingClientRect();
const own = card.getBoundingClientRect();
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
// 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 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));
}
centredRef.current = null;
return;
}
if (--left > 0) requestAnimationFrame(settle);
else centredRef.current = null;
@@ -2187,7 +2231,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, dayStarts, wall.ref, up]);
}, [view, listed, 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).
@@ -2229,7 +2273,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// looked up in `listed` too, because a pick made in another folder is not a
// frame this shelf can raise.
const openFromWall = () => {
const el = wall.ref.current;
const el = gridRef.current;
const picked =
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
? lastSelectedId
@@ -3147,81 +3191,100 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
{view === 'grid' ? (
<>
{/* 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
<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}
{paintedDays.map((day) => (
<section className="adm-day" key={day.key} data-key={`lib-day-${day.key}`}>
{/* The day above its frames, the way the phone's own grid
reads a shelf: a card still says its date, and the band
is what makes a roll scannable by the month it was shot. */}
<h3 className="adm-day-head">
<span>{dayLabel(day.at, lang)}</span>
<span className="adm-day-line" aria-hidden="true" />
</h3>
<div className="adm-day-grid">
{day.photos.map((photo) => (
<article
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
{/* The wall is the shelf itself — every day of it, drawn — so
its scrollbar on the right edge reaches every frame and a
day scrolled to is a day that was already there. The rail
beside it is the shelf's time: the month the eye is in,
and a press on one takes the wall to it. */}
<div className="lib-wall">
<div className="adm-grid" data-key="lib-wall" ref={gridRef}>
{days.map((day) => (
<section className="adm-day" key={day.key} data-key={`lib-day-${day.key}`}>
{/* The day above its frames, the way the phone's own grid
reads a shelf: a card still says its date, and the band
is what makes a roll scannable by the month it was shot. */}
<h3 className="adm-day-head">
<span>{dayLabel(day.at, lang)}</span>
<span className="adm-day-line" aria-hidden="true" />
</h3>
<div className="adm-day-grid">
{day.photos.map((photo) => (
<article
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
key={photo.id}
data-key={`lib-photo-${photo.id}`}
onClick={(e) => handleSelectPhoto(photo, e)}
onDoubleClick={() => {
setUp(photo.id);
scrolledFrameRef.current = null;
setView('preview');
}}
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{photo.star ? `★${photo.star} · ` : ''}
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
</article>
))}
</div>
</section>
))}
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{photo.star ? `★${photo.star} · ` : ''}
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
{/* The rail beside the wall: the shelf's own time, one
press away from any month of it, and the entry the eye
is in said in the accent. It is read off the days the
wall was cut into (`rail`), so an entry and the header
over the frames it names are one day and not two. */}
<nav className="lib-rail" data-key="lib-rail" ref={railRef} aria-label={t('lib.rail')}>
{rail.map((mark, i) => (
<button
key={mark.at}
type="button"
className={`lib-rail-item${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.at}`}
aria-current={i === railAt ? 'true' : undefined}
title={mark.title}
onClick={() => jumpToTime(mark.at)}
>
{mark.label}
</button>
))}
</nav>
</div>
</>
) : frame ? (
@@ -4097,181 +4160,3 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
}, [count, on]);
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,
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 }));
// The window last written, because the reader of a `setWin` in a layout effect
// is not the state: a set made from a layout effect renders again whether or
// not the value moved, and this one is remeasured after every render — so a set
// it did not need is the screen's own render loop (React #185, "Maximum update
// depth exceeded"). The ref is the guard: nothing is set that the wall already
// stands at.
const winRef = useRef(win);
const measureRef = useRef<() => void>(() => {});
// 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
// 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;
// The window is only ever set through here: a `setWin` the wall already
// stands at is a render bought for nothing, and this is measured after every
// render — that is the loop (React #185).
// And the second guard, for a reader the first one cannot see: a window
// written off the window before it is a window that can write itself, and
// whatever the shelf does between the two there are only so many turns of
// that before React gives up with #185 — a blank screen, this app having no
// error boundary between it and the floor. `ponytail:` past the count the
// wall stands still, one window behind, until something outside moves — the
// next scroll, resize, or count hands the pen straight back.
let carries = 0;
const show = (next: { from: number; to: number; above: number; below: number }) => {
const prev = winRef.current;
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
if (carries >= WALL_CARRIES) return;
carries++;
winRef.current = next;
setWin(next);
};
const measure = () => {
// The window is measured off the wall that is up, not off the one this
// effect was set up on: the stage is drawn while a node is open, so a visit
// that loses its nodes and gets them back draws a new wall, and a window
// measured off the old node — a box of nothing, a scroll always nought — is
// a window that never moves again for the rest of the visit.
const now = ref.current;
if (!now) return;
// A wall that is not standing has no box to measure: the library is
// `display:none` while the studio is up, and a window read off it would be
// a window read off nothing — every card reports `offsetTop` 0 and every
// scroll 0 — which threw the shelf back to its top every time the studio
// handed back to it. It is read on the frame it comes back up, which is a
// box change.
if (!now.clientHeight) return;
// And a window that is nowhere on the shelf stands nowhere: outside a shelf
// that shrank under it — another folder, a filter, a shorter roll — or empty
// while the shelf is not, which is the first paint, before the catalogue has
// landed. This is the one reset, and it is written off the shelf's own
// length rather than off the cards the last window drew, which is what made
// the reset a window the window fed.
const held = winRef.current.to - winRef.current.from;
if (held <= 0 || winRef.current.from >= count || winRef.current.to > count) {
show({ from: 0, to: Math.min(count, 24), above: 0, below: 0 });
return;
}
// How many columns is the grid's own business, read off its layout and not
// counted off the cards the window drew: the drawn run is a whole number of
// rows, those rows were read back into the count, and a count read off the
// 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 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) {
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 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;
const onOutside = () => {
carries = 0;
measure();
};
el.addEventListener('scroll', onOutside, { passive: true });
const ro = new ResizeObserver(onOutside);
ro.observe(el);
return () => {
measureRef.current = () => {};
el.removeEventListener('scroll', onOutside);
ro.disconnect();
};
}, [count, dayStarts, on]);
useLayoutEffect(() => {
measureRef.current();
});
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
}
+2
View File
@@ -278,6 +278,8 @@ export const en: Dict = {
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
'lib.viewPreview': 'SINGLE',
'lib.viewGrid': 'ALL THUMBS',
// The column of time down the wall's right edge (see `Library.tsx`).
'lib.rail': 'Timeline',
'lib.prev': 'Previous frame',
'lib.next': 'Next frame',
'lib.subsShow': 'SHOW SUBFOLDERS',
+2
View File
@@ -289,6 +289,8 @@ export const vi = {
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
'lib.viewPreview': 'MỘT ẢNH',
'lib.viewGrid': 'TOÀN BỘ',
// Cột thời gian bên phải bày ảnh (xem `Library.tsx`).
'lib.rail': 'Dòng thời gian',
'lib.prev': 'Ảnh trước',
'lib.next': 'Ảnh sau',
'lib.subsShow': 'HIỆN CẢ THƯ MỤC CON',
+36 -4
View File
@@ -1249,9 +1249,6 @@ form[data-key='immich-modal'] {
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;
@@ -1484,7 +1481,42 @@ form[data-key='immich-modal'] {
fewer frames than there are columns is one row, and a stretched row makes every
card's border that much taller than the picture inside it. Rows keep the height
their own frames give them however few of them there are. */
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; }
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding-right: 6px; overflow-anchor: none; align-content: start; }
/* The wall and the rail beside it: the wall is the shelf and takes the room, the
rail is the time down its right edge and takes what it needs. The wall's own
scrollbar is inside its box, so the rail stands outside the frames rather than
over them. */
.lib-wall { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; gap: 6px; }
.lib-rail {
position: relative;
flex: 0 0 auto;
width: 86px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
padding: 2px 0;
border-left: 1px solid var(--border);
}
.lib-rail::-webkit-scrollbar { display: none; }
/* An entry of the rail: the month or the day, said in the dim voice until the eye
is in it, and in the accent — with the rule that carries the eye — once it is. */
.lib-rail-item {
display: block;
width: 100%;
padding: 3px 8px;
border: 0;
border-left: 2px solid transparent;
background: none;
color: var(--text-dim);
font: inherit;
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.lib-rail-item:hover { color: var(--text); background: var(--bg-sunken); }
.lib-rail-item.on { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
/* What the thumbnail wall is read through: the selects and the count of what is
left. One row above the wall, and it does not scroll with it. */
.lib-root .adm-stage > .lib-filters,
+14 -154
View File
@@ -21,6 +21,20 @@ export function dayLabel(timestamp: number, lang: Lang): string {
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
}
// The month a day is filed under, and the line it is read by in the rail down the
// wall's right edge. Built on `dayKey`'s own spelling — a month is the first seven
// characters of the day — so the rail's entries and the days it jumps to are cut
// by one rule and not two.
export function monthKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 7);
}
// "thg 10, 2026" / "Oct 2026".
export function monthLabel(timestamp: number, lang: Lang): string {
const d = new Date(timestamp);
return lang === 'vi' ? `thg ${d.getMonth() + 1}, ${d.getFullYear()}` : d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
export type DayRun<T> = { key: string; at: number; photos: T[] };
// The runs of one day, cut off the list as it stands rather than sorted into
@@ -38,157 +52,3 @@ export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
}
return runs;
}
// The rows a shelf of days stands in, which is not `count / cols`: a day is a
// band of its own above its frames — the band spans the grid (`grid-column:
// 1 / -1`) and the frames start on the row after it — so every day spends one
// whole row on its band, and a day whose frames do not come out even over the
// columns leaves the rest of its last row empty, since the next day's band
// starts a row of its own. Counted flat, the model stands for fewer rows than
// the DOM draws, and what stands in for the rows not drawn is a spacer over the
// frames below the fold that were never drawn: the reader scrolls to a screen of
// nothing and stops there.
//
// `bandRows` is that count, day by day: the row each day's band stands in, and
// the row after the last day — the shelf's own height in rows. Every row below a
// band is `cols` frames of that day, so a row is turned back into a frame
// through the band above it and the frames of the day before it.
export function bandRows(dayStarts: number[], count: number, cols: number): number[] {
const rows = new Array<number>(dayStarts.length + 1);
let row = 0;
for (let k = 0; k < dayStarts.length; k++) {
rows[k] = row;
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
row += 1 + Math.ceil(frames / cols);
}
rows[dayStarts.length] = row;
return rows;
}
// The two steps a shelf is stacked in, and what the grid leaves between them: a
// day is a band of `band` over rows of frames of `row`, and `gap` stands under
// each of them. They are not one step. A step taken as their average — read off
// the rows the window happens to be drawing — is a step that is wrong for both,
// and a shelf whose days do not come out even over the columns lays the two in a
// different proportion from one screen to the next. What that costs is the end of
// the shelf: the spacers standing in for the rows not drawn add up to a height
// that moves with the window, the scrollbar ends above the frames that were never
// drawn, and the screen under the last card drawn is a screen of nothing.
export type WallSteps = { band: number; row: number; gap: number };
// The shelf measured in those steps: the row every day's band stands in
// (`order`, the same rows `rowPhoto` reads), the top and the bottom of every
// day's own box, and the height of the whole shelf, which is the bottom of the
// last day's box.
export type WallLayout = {
steps: WallSteps;
cols: number;
rows: number;
order: number[];
tops: number[];
ends: number[];
height: number;
};
export function wallLayout(dayStarts: number[], count: number, cols: number, steps: WallSteps): WallLayout {
const order = bandRows(dayStarts, count, cols);
const tops: number[] = [];
const ends: number[] = [];
let y = 0;
for (let k = 0; k < dayStarts.length; k++) {
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
const height = steps.band + Math.ceil(frames / cols) * steps.row;
tops.push(y);
ends.push(y + height);
y += height + steps.gap;
}
return { steps, cols, rows: order[dayStarts.length], order, tops, ends, height: ends.length ? ends[ends.length - 1] : 0 };
}
// The day a row stands in, off the rows every day's band stands in. The last
// entry of `order` is the row after the last day, which is not a day's own.
function dayOfRow(order: number[], row: number): number {
let lo = 0;
let hi = Math.max(0, order.length - 2);
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (order[mid] <= row) lo = mid;
else hi = mid - 1;
}
return lo;
}
// The top of a row's own box: a day's band stands at the top of the day, and the
// frame rows under it start where the band's own height ends.
export function rowTop(layout: WallLayout, row: number): number {
if (!layout.tops.length) return 0;
const k = dayOfRow(layout.order, row);
return row <= layout.order[k]
? layout.tops[k]
: layout.tops[k] + layout.steps.band + (row - layout.order[k] - 1) * layout.steps.row;
}
// And the other way: the row a scroll position falls in, so the window the wall
// draws is the window the reader is standing in.
export function rowAt(layout: WallLayout, y: number): number {
if (!layout.tops.length || y <= 0) return 0;
let lo = 0;
let hi = layout.tops.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (layout.tops[mid] <= y) lo = mid;
else hi = mid - 1;
}
const rel = y - layout.tops[lo];
if (rel < layout.steps.band) return layout.order[lo];
const under = Math.floor((rel - layout.steps.band) / layout.steps.row) + 1;
return Math.min(layout.order[lo + 1] - 1, layout.order[lo] + under);
}
// The day a frame is filed under, on the same cut the days were begun at.
export function dayAt(dayStarts: number[], index: number): number {
if (!dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (dayStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return lo;
}
// The first frame drawn in `row` — where a row starts drawing, since a frame is
// drawn in the row its own slot over the columns falls in. A band is a row of
// its own and draws its day's first frame; the rows under it draw that day's
// frames `cols` at a time, and a row past a day's frames draws the first frame
// of the day after it, whose band is the row it lands on. Rows meet where one
// answers and the next takes up from there, which is what makes a run of them a
// window over the shelf with no frame left out between two of them.
export function rowPhoto(dayStarts: number[], count: number, cols: number, order: number[], row: number): number {
if (row <= 0 || !dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (order[mid] <= row) lo = mid;
else hi = mid - 1;
}
const end = lo + 1 < dayStarts.length ? dayStarts[lo + 1] : count;
const drawn = row - order[lo] - 1;
return drawn <= 0 ? dayStarts[lo] : Math.min(end, dayStarts[lo] + drawn * cols);
}
// The row the frame at `index` is drawn in — the settle effect's own way of
// putting a scroll where a frame is, off the same rows as the window above.
export function photoRow(dayStarts: number[], cols: number, order: number[], index: number): number {
if (!dayStarts.length) return 0;
let lo = 0;
let hi = dayStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (dayStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return order[lo] + 1 + Math.floor((index - dayStarts[lo]) / cols);
}