fix(library): draw the wall whole and read its time down the right edge
This commit is contained in:
@@ -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(' // '));
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user