From 62f244f3c27bcf550efbfebb4e4e30eb6599f9df Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 09:38:05 +0700 Subject: [PATCH] fix(library): draw the wall whole and read its time down the right edge --- docker/frontend/scripts/wall-check.mjs | 290 +++++++--- docker/frontend/scripts/wall-slot-check.mjs | 146 ----- docker/frontend/src/Library.tsx | 567 ++++++++------------ docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + docker/frontend/src/styles/app.css | 40 +- docker/frontend/src/ui/dateGroups.ts | 168 +----- 7 files changed, 509 insertions(+), 706 deletions(-) delete mode 100644 docker/frontend/scripts/wall-slot-check.mjs diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs index a5cd2de..0254033 100644 --- a/docker/frontend/scripts/wall-check.mjs +++ b/docker/frontend/scripts/wall-check.mjs @@ -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= 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(' // ')); diff --git a/docker/frontend/scripts/wall-slot-check.mjs b/docker/frontend/scripts/wall-slot-check.mjs deleted file mode 100644 index 4c724ba..0000000 --- a/docker/frontend/scripts/wall-slot-check.mjs +++ /dev/null @@ -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`); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 2cb6456..66a615f 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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 `` 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(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(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('.adm-day')]; + if (!nodes.length) return; + const place = new Map(nodes.map((node, i) => [node.dataset.key ?? '', i])); + const seen = new Set(); + 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(`[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(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('.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 - and an object URL of its own, and twenty thousand - of them is a dead screen. */} -
- {wall.above > 0 ?
+ + ))} + + {/* 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. */} + ) : frame ? ( @@ -4097,181 +4160,3 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject` and an object URL -// of its own; twenty thousand of them is a dead screen for as long as they take to -// build. `on` is the wall being up, since the element measured is its own. -function useWallWindow( - count: number, - dayStarts: number[], - on: boolean -): { - ref: RefObject; - 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(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({ 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('.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('.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 }; -} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index f41edf4..cfa494a 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 5d9106a..28e60ec 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index fb6eddb..c8d639a 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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, diff --git a/docker/frontend/src/ui/dateGroups.ts b/docker/frontend/src/ui/dateGroups.ts index 1b0adbf..0cb10d4 100644 --- a/docker/frontend/src/ui/dateGroups.ts +++ b/docker/frontend/src/ui/dateGroups.ts @@ -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 = { 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(photos: T[], at: (photo: T) => number): DayRun[] { } 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(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); -}