feat(library): lay the ALL THUMBS wall out in justified rows per day, and stop clipping the frames at the pane's edges
This commit is contained in:
@@ -243,12 +243,73 @@ await context.addInitScript(
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
|
||||
|
||||
// The day a frame of this roll was shot on, on the reader's own clock — the same
|
||||
// cut the wall files it under.
|
||||
const dayKeyOf = (name) => {
|
||||
const d = new Date(dayOfName(name));
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// What the wall is drawing, read off the boxes: every row it has drawn, the frames
|
||||
// in them, and the pane and body they are drawn in. The whole check is read off
|
||||
// this, so a row standing at a top the layout did not give it is a row this sees.
|
||||
const audit = () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
if (!el) return null;
|
||||
const box = el.getBoundingClientRect();
|
||||
const body = el.querySelector('.adm-wall-body');
|
||||
const rows = [...el.querySelectorAll('.adm-row')].map((row) => {
|
||||
const rb = row.getBoundingClientRect();
|
||||
const month = row.querySelector('.adm-month-head');
|
||||
return {
|
||||
band: row.classList.contains('adm-band'),
|
||||
day: row.getAttribute('data-day') ?? '',
|
||||
label: row.querySelector('.adm-day-head')?.textContent?.trim() ?? '',
|
||||
month: month ? (month.getAttribute('data-key') ?? '').replace('lib-month-', '') : '',
|
||||
top: Math.round(rb.top - box.top),
|
||||
bottom: Math.round(rb.bottom - box.top),
|
||||
tiles: [...row.querySelectorAll('.lib-tile')].map((t) => {
|
||||
const tb = t.getBoundingClientRect();
|
||||
const img = t.querySelector('img');
|
||||
return {
|
||||
name: (t.getAttribute('data-key') ?? '').split('/').pop(),
|
||||
// Kept as the browser laid them out: a row that is justified to the wall
|
||||
// is a sum of widths, and rounding each of them is a sum off by up to
|
||||
// half a frame a piece.
|
||||
w: Number(tb.width.toFixed(2)),
|
||||
h: Number(tb.height.toFixed(2)),
|
||||
ar: img && img.naturalWidth > 0 ? Number((img.naturalWidth / img.naturalHeight).toFixed(3)) : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const run = document.querySelector('.adm-row:not(.adm-band)');
|
||||
return {
|
||||
scrollTop: Math.round(el.scrollTop),
|
||||
clientH: Math.round(el.clientHeight),
|
||||
clientW: Math.round(el.clientWidth),
|
||||
scrollH: el.scrollHeight,
|
||||
bodyH: body ? Math.round(body.getBoundingClientRect().height) : null,
|
||||
gap: run ? Number.parseFloat(getComputedStyle(run).columnGap) || 0 : 0,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
// A stop on the shelf: a fraction of the room under it, or its own end at -1.
|
||||
const scrollTo = (f) =>
|
||||
page.evaluate((at) => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
el.scrollTop = at < 0 ? el.scrollHeight : Math.round((el.scrollHeight - el.clientHeight) * at);
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
}, f);
|
||||
|
||||
const read = () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
return {
|
||||
cards: el ? el.querySelectorAll('.adm-card').length : 0,
|
||||
wall: el ? el.scrollHeight > el.clientHeight : false,
|
||||
tiles: el ? el.querySelectorAll('.lib-tile').length : 0,
|
||||
// The white screen itself: no rail, no words, nothing drawn.
|
||||
blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'),
|
||||
};
|
||||
@@ -267,88 +328,103 @@ await page
|
||||
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
|
||||
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
|
||||
|
||||
// The day the header of a card stands under, read off the sections themselves:
|
||||
// every header must name the day of every card filed under it. A day whose first
|
||||
// rows are above the window still gets its header where the eye lands (see
|
||||
// `dayRuns`), so the rule is "a header names its own cards" and not "one header
|
||||
// per day in the shelf".
|
||||
const dayAudit = () =>
|
||||
page.evaluate(() => {
|
||||
const key = (ts) => {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
const out = { sections: 0, cards: 0, labelled: 0, bad: [] };
|
||||
for (const section of document.querySelectorAll('.adm-day')) {
|
||||
out.sections++;
|
||||
const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
|
||||
const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? '';
|
||||
if (head) out.labelled++;
|
||||
const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map(
|
||||
(el) => (el.getAttribute('data-key') ?? '').split('/').pop()
|
||||
);
|
||||
const days = new Set(names.map((name) => key(window.__dayOf(name))));
|
||||
if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) });
|
||||
}
|
||||
out.cards = document.querySelectorAll('.adm-card').length;
|
||||
return out;
|
||||
});
|
||||
|
||||
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 });
|
||||
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');
|
||||
await page.waitForSelector('.adm-row', { timeout: 15_000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lib-tile').length > 0, null, { timeout: 30_000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
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('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.
|
||||
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
|
||||
const seenDays = new Set();
|
||||
let scrolledBad = [];
|
||||
for (const at of stops) {
|
||||
await page.evaluate((f) => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) * f);
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
}, 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 opened = await audit();
|
||||
const openedTiles = opened.rows.reduce((n, r) => n + r.tiles.length, 0);
|
||||
step('the wall draws a screenful of the shelf', openedTiles > 0 && openedTiles < FRAMES, `${openedTiles} of ${FRAMES} frames drawn`);
|
||||
step('and stands in for the rows it is not drawing', opened.scrollH > opened.clientH, `a shelf of ${opened.scrollH}px in a pane of ${opened.clientH}px`);
|
||||
// The shelf's own height, which is the layout's and not the DOM's: a body standing
|
||||
// shorter than the layout puts the frames under the fold out of reach, and one
|
||||
// standing taller leaves the reader at the end of the scroll with a screen of
|
||||
// nothing under the last frame drawn.
|
||||
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))}`
|
||||
'the body of the wall stands the shelf the layout counted',
|
||||
opened.bodyH !== null && Math.abs(opened.scrollH - opened.bodyH) <= 1,
|
||||
`body ${opened.bodyH}px, scroll ${opened.scrollH}px`
|
||||
);
|
||||
|
||||
// The two frames the pane is drawn for, and the ones a shelf that moves under the
|
||||
// reader hides: the row over the top edge of the pane and the row under its bottom
|
||||
// edge are rows the wall has to have drawn, at every stop of the roll, its two
|
||||
// ends included. Read down the shelf and not at one stop, since a stop of any one
|
||||
// fraction lands inside a day often as it lands on a fold.
|
||||
const stops = [0, 0.15, 0.35, 0.55, 0.75, 0.95, -1];
|
||||
// Read down the drawn rows, from the top edge of the pane to its bottom one, and
|
||||
// the holes between them: two rows of the wall stand a gap apart, and a gap is
|
||||
// not a hole — anything wider than the gap is a row the window left out.
|
||||
const holes = [];
|
||||
const SLIT = 4;
|
||||
const unjustified = [];
|
||||
const dayBad = [];
|
||||
const seenDays = new Set();
|
||||
for (const at of stops) {
|
||||
await scrollTo(at);
|
||||
await page.waitForTimeout(900);
|
||||
const a = await audit();
|
||||
let cursor = 0;
|
||||
for (const r of [...a.rows].sort((x, y) => x.top - y.top)) {
|
||||
if (r.bottom <= 0) continue;
|
||||
if (r.top > cursor + SLIT) holes.push({ at, slit: [cursor, r.top] });
|
||||
if (r.bottom > cursor) cursor = r.bottom;
|
||||
}
|
||||
if (cursor < a.clientH - 1) holes.push({ at, open: [cursor, a.clientH] });
|
||||
// A row of frames is justified to the wall: the frames of a day packed into a row
|
||||
// until it is at least as wide as the wall, and the row then stretched to exactly
|
||||
// that width, so every frame keeps its own proportions and meets its neighbours.
|
||||
// Every row of a day but the last is that width; the last one is as wide as the
|
||||
// day's frames come out, which is what tells one day from the next.
|
||||
const run = a.rows.filter((r) => !r.band);
|
||||
for (let i = 0; i < run.length; i++) {
|
||||
const row = run[i];
|
||||
if (!row.tiles.length) continue;
|
||||
const sum = row.tiles.reduce((n, t) => n + t.w, 0) + a.gap * (row.tiles.length - 1);
|
||||
const last = i + 1 >= run.length || run[i + 1].day !== row.day;
|
||||
if (Math.abs(sum - a.clientW) > 1 && !(last && sum < a.clientW)) {
|
||||
unjustified.push({ day: row.day, sum, wall: a.clientW, last });
|
||||
}
|
||||
for (const t of row.tiles) {
|
||||
if (t.ar !== null && Math.abs(t.w / t.h - t.ar) > 0.02) {
|
||||
unjustified.push({ day: row.day, name: t.name, ar: t.ar, drawn: Number((t.w / t.h).toFixed(3)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the bands: a band names the day it stands over, and the frames under it are
|
||||
// the frames of that day.
|
||||
let head = null;
|
||||
for (const row of a.rows) {
|
||||
if (row.band) {
|
||||
head = row.day;
|
||||
if (head) seenDays.add(head);
|
||||
continue;
|
||||
}
|
||||
// The first row drawn may begin under a band the window left above the fold.
|
||||
if (!head) continue;
|
||||
if (row.day !== head) dayBad.push({ head, row: row.day });
|
||||
for (const t of row.tiles) {
|
||||
if (t.name && head !== dayKeyOf(t.name)) dayBad.push({ head, name: t.name, own: dayKeyOf(t.name) });
|
||||
}
|
||||
}
|
||||
}
|
||||
step('the frames fill the pane from its top edge to its bottom one', holes.length === 0, JSON.stringify(holes.slice(0, 2)));
|
||||
step('a row of frames is justified to the wall, and a day ends in a row of its own', unjustified.length === 0, JSON.stringify(unjustified.slice(0, 2)));
|
||||
step(
|
||||
'every row of frames stands under the band that names its day',
|
||||
dayBad.length === 0 && seenDays.size > 1,
|
||||
`${seenDays.size} days through the window, ${JSON.stringify(dayBad.slice(0, 2))}`
|
||||
);
|
||||
|
||||
// Back to the head of the roll, where the month's own band is drawn.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
await scrollTo(0);
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// The two bands, at the heights the wall counts its shelf in. The month's and the
|
||||
// day's band are both fixed numbers in the style sheet and the shelf's whole
|
||||
// length — and with it every spacer standing in for the rows not drawn — is
|
||||
// counted from them, so a band that grew with its own text is a shelf whose end
|
||||
// moves.
|
||||
// The two bands, at the heights the wall lays its shelf out in: both are fixed
|
||||
// numbers in the style sheet, and the shelf's whole height — and with it every
|
||||
// frame's own top — is counted from them, so a band that grew with its own text is
|
||||
// a shelf whose end moves.
|
||||
const bands = await page.evaluate(() => {
|
||||
const h = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
@@ -356,34 +432,27 @@ const bands = await page.evaluate(() => {
|
||||
};
|
||||
return { month: h('.adm-month-head'), day: h('.adm-day-head') };
|
||||
});
|
||||
step('the month and the day bands stand at the heights the wall counts them at', bands.month === 54 && bands.day === 28, JSON.stringify(bands));
|
||||
step('the month and the day bands stand at the heights the wall lays them in', bands.month === 44 && bands.day === 30, JSON.stringify(bands));
|
||||
|
||||
// The month's own band, over the first day of the month it names and over no
|
||||
// other: the wall is read by the month it was shot and only then by the day, and
|
||||
// a band left standing over a day of the month above it is a month named twice.
|
||||
const monthAudit = () =>
|
||||
page.evaluate(() => {
|
||||
const out = { sections: 0, heads: 0, bad: [] };
|
||||
const sections = [...document.querySelectorAll('.adm-day')];
|
||||
out.sections = sections.length;
|
||||
let prev = null;
|
||||
for (const section of sections) {
|
||||
const month = section.getAttribute('data-month') ?? '';
|
||||
const head = section.querySelector('.adm-month-head');
|
||||
const named = head ? (head.getAttribute('data-key') ?? '').replace('lib-month-', '') : '';
|
||||
if (head) out.heads++;
|
||||
// The window's own first day is not read: it is a day the wall began in the
|
||||
// middle of, and the band above it was drawn above the fold or not at all.
|
||||
if (prev !== null) {
|
||||
if ((month !== prev) !== Boolean(head)) out.bad.push({ month, prev, head: Boolean(head) });
|
||||
else if (head && named !== month) out.bad.push({ month, named });
|
||||
}
|
||||
prev = month;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const months1 = await monthAudit();
|
||||
step('the wall names a month over the first day of it, and only there', months1.heads >= 1 && months1.bad.length === 0, `${months1.heads} month bands over ${months1.sections} days, ${JSON.stringify(months1.bad.slice(0, 2))}`);
|
||||
// other: the wall is read by the month it was shot and only then by the day, and a
|
||||
// band left standing over a day of the month above it is a month named twice.
|
||||
const head = await audit();
|
||||
const monthBad = [];
|
||||
let prev = null;
|
||||
let monthBands = 0;
|
||||
for (const row of head.rows) {
|
||||
if (!row.band) continue;
|
||||
const month = row.day.slice(0, 7);
|
||||
if (row.month) monthBands++;
|
||||
// The window's own first day is not read: it is a day the wall began in the
|
||||
// middle of, and the band above it was drawn above the fold or not at all.
|
||||
if (prev !== null && ((month !== prev) !== Boolean(row.month) || (row.month && row.month !== month))) {
|
||||
monthBad.push({ day: row.day, named: row.month, month });
|
||||
}
|
||||
prev = month;
|
||||
}
|
||||
step('the wall names a month over the first day of it, and only there', monthBands >= 1 && monthBad.length === 0, `${monthBands} month bands, ${JSON.stringify(monthBad.slice(0, 2))}`);
|
||||
|
||||
// The ruler down the right edge: a tick a month of the roll and a named tick a
|
||||
// year of it, on the shelf's own months and not on a calendar — a month nothing
|
||||
@@ -410,11 +479,11 @@ await page.click(`[data-key="${press.tick}"]`);
|
||||
await page.waitForTimeout(1500);
|
||||
const landed = await page.evaluate((key) => {
|
||||
const grid = document.querySelector('.adm-grid');
|
||||
const head = document.querySelector(`[data-key="lib-month-${key}"]`);
|
||||
const band = document.querySelector(`[data-key="lib-month-${key}"]`);
|
||||
const on = document.querySelector('.lib-ruler-tick.on');
|
||||
return {
|
||||
drawn: Boolean(head),
|
||||
offset: head && grid ? Math.round(head.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
|
||||
drawn: Boolean(band),
|
||||
offset: band && grid ? Math.round(band.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
|
||||
value: document.querySelector('[data-key="lib-ruler-value"]')?.textContent?.trim() ?? '',
|
||||
on: on ? on.getAttribute('data-at') : '',
|
||||
};
|
||||
@@ -426,18 +495,18 @@ step(
|
||||
`on=${landed.on} of ${press.at}, " ${landed.value}"`
|
||||
);
|
||||
|
||||
// A notch of the wheel, over the ruler: one month, whichever way the roll is
|
||||
// read, and never off the end of it.
|
||||
// A notch of the wheel, over the ruler: one month, whichever way the roll is read,
|
||||
// and never off the end of it.
|
||||
const notch = () =>
|
||||
page.evaluate(() => {
|
||||
const ticksAll = [...document.querySelectorAll('.lib-ruler-tick')];
|
||||
const months = ticksAll.filter((t) => t.getAttribute('data-kind') === 'month');
|
||||
const months = [...document.querySelectorAll('.lib-ruler-tick')].filter((t) => t.getAttribute('data-kind') === 'month');
|
||||
const on = months.find((t) => t.classList.contains('on'));
|
||||
const needle = document.querySelector('[data-key="lib-ruler-needle"]');
|
||||
return {
|
||||
on: months.findIndex((t) => t.classList.contains('on')),
|
||||
count: months.length,
|
||||
needle: needle ? parseFloat(needle.style.top) || 0 : 0,
|
||||
tick: months.find((t) => t.classList.contains('on')) ? parseFloat(months.find((t) => t.classList.contains('on')).style.top) || 0 : 0,
|
||||
needle: needle ? Number.parseFloat(needle.style.top) || 0 : 0,
|
||||
tick: on ? Number.parseFloat(on.style.top) || 0 : 0,
|
||||
};
|
||||
});
|
||||
const beforeNotch = await notch();
|
||||
@@ -452,16 +521,16 @@ step('and the needle stands in the month the eye is in', Math.abs(afterNotch.nee
|
||||
|
||||
// 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.
|
||||
// draws the rows around the eye and stands in for the rest with its own height, so
|
||||
// a body standing shorter than the layout leaves the reader at the end of the
|
||||
// scroll on a screen of nothing 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.
|
||||
const walked = await page.evaluate(async () => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
const seen = new Set();
|
||||
const collect = () => {
|
||||
for (const card of el.querySelectorAll('.adm-card')) seen.add(card.getAttribute('data-key'));
|
||||
for (const tile of el.querySelectorAll('.lib-tile')) seen.add(tile.getAttribute('data-key'));
|
||||
};
|
||||
const breathe = () => new Promise((r) => setTimeout(r, 150));
|
||||
el.scrollTop = 0;
|
||||
@@ -474,21 +543,21 @@ const walked = await page.evaluate(async () => {
|
||||
await breathe();
|
||||
}
|
||||
collect();
|
||||
const cards = [...el.querySelectorAll('.adm-card')];
|
||||
const last = cards[cards.length - 1];
|
||||
const box = el.getBoundingClientRect();
|
||||
const body = el.querySelector('.adm-wall-body');
|
||||
const tiles = [...el.querySelectorAll('.lib-tile')];
|
||||
const last = tiles[tiles.length - 1];
|
||||
return {
|
||||
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.
|
||||
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : null,
|
||||
// What is left under the last frame drawn: the body that has nothing left to
|
||||
// stand in for.
|
||||
gap: last && body ? Math.round(body.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom) : 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`);
|
||||
step('and stands on nothing there', walked.gap !== null && walked.gap <= 2, `${walked.gap}px of nothing under the last frame`);
|
||||
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'}`);
|
||||
|
||||
@@ -506,10 +575,10 @@ 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,
|
||||
`blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}`
|
||||
!after.blank && after.tiles > 0 && looped.length === 0,
|
||||
`blank=${after.blank}, tiles=${after.tiles}, errors=${looped.length ? looped[0] : 'none'}`
|
||||
);
|
||||
step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`);
|
||||
step('the wall is still the wall', after.tiles > 0, `${after.tiles} frames after the feed`);
|
||||
|
||||
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
|
||||
if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // '));
|
||||
|
||||
+351
-353
@@ -227,22 +227,29 @@ const WALL_LANES = 2;
|
||||
// the one they are looking at. The wall mints the rest as it scrolls, which is
|
||||
// who the queue was for.
|
||||
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;
|
||||
// The two bands the wall opens a run of frames with: the month's own line, then
|
||||
// the day's. Both are heights the style sheet fixes, and these are those two
|
||||
// numbers (`.adm-month-head` / `.adm-day-head` in styles/app.css): the shelf's
|
||||
// whole height is built out of them before a single card is drawn, which is what
|
||||
// lets the spacer stand in for a month nobody has scrolled to.
|
||||
const MONTH_BAND = 54;
|
||||
const DAY_BAND = 28;
|
||||
// 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;
|
||||
// What the wall leaves between two frames, and between two rows of them: the wall
|
||||
// is one shelf of pictures and not a grid of cards, so the gap is the one a
|
||||
// contact sheet leaves — enough to tell two frames apart, little enough that a
|
||||
// day still reads as a day.
|
||||
const WALL_GAP = 3;
|
||||
// The height a row of frames is justified to, and the two bands the wall opens a
|
||||
// run of frames with: the month's own line, then the day's. The bands are heights
|
||||
// the style sheet fixes, and these are those two numbers (`.adm-month-head` /
|
||||
// `.adm-day-head` in styles/app.css); `target` is the wall's own, since a row is
|
||||
// laid out to the width it is given and not to a height. The shelf's whole height
|
||||
// is built out of the three before a single frame is drawn, which is what keeps
|
||||
// the scrollbar the length of the roll.
|
||||
const ROW_TARGET = 168;
|
||||
const MONTH_BAND = 44;
|
||||
const DAY_BAND = 30;
|
||||
// The shelf's own stacking, read off the layout in one place and nowhere else.
|
||||
const WALL_STEPS: WallSteps = { head: MONTH_BAND, band: DAY_BAND, target: ROW_TARGET, gap: WALL_GAP };
|
||||
// The line the eye stands on, a few pixels under the pane's own top edge: the row
|
||||
// a reader is reading is the row that carries that line, not the row the pane
|
||||
// happens to cut through, and a band standing a few pixels above the fold is the
|
||||
// band the pane is in. The ruler's needle reads the month of that row, and a jump
|
||||
// to a month leaves its band exactly on the line.
|
||||
const WALL_EYE = 8;
|
||||
// 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);
|
||||
@@ -367,7 +374,7 @@ export function Library() {
|
||||
}
|
||||
|
||||
import { useIsMobile } from './ui/useIsMobile';
|
||||
import { bandRows, dayAt, dayKey, dayLabel, dayRuns, monthKey, monthLabel, monthTitle, photoRow, rowAt, rowPhoto, rowTop, wallLayout, yearKey, type WallSteps } from './ui/dateGroups';
|
||||
import { dayKey, dayLabel, dayRow, monthKey, monthLabel, monthTitle, rowAt, rowOf, wallLayout, WALL_AR, yearKey, type WallRow, type WallSteps } from './ui/dateGroups';
|
||||
|
||||
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
const { t, lang } = useI18n();
|
||||
@@ -2051,32 +2058,86 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
}
|
||||
return { dayStarts: starts, dayHeads: heads };
|
||||
}, [listed]);
|
||||
const wall = useWallWindow(listed.length, dayStarts, dayHeads, view === 'grid');
|
||||
// A row is justified to the wall's own width at `ROW_TARGET`, which needs the
|
||||
// shape of every frame in it — and the shape is the one thing the catalogue
|
||||
// cannot be asked for: a scan reads a frame's date and its size off the file,
|
||||
// not its width and its height, and decoding a whole roll to find out is a shelf
|
||||
// that takes an age to stand up. The picture itself says, on the frame the
|
||||
// browser hands it over (`onAr`), and the wall lays the shelf out again with
|
||||
// what it has said; a frame still unread stands at the average rectangle of the
|
||||
// ones that are not, which is as little as a row can be wrong by before its own
|
||||
// pictures land.
|
||||
const [arsAt, setArsAt] = useState(0);
|
||||
const arRaf = useRef(0);
|
||||
const bumpArs = useCallback(() => {
|
||||
// One bump a frame, whatever a screenful of pictures says: a set of state per
|
||||
// load is a render per load, and a row of twenty is twenty of them.
|
||||
if (arRaf.current) return;
|
||||
arRaf.current = requestAnimationFrame(() => {
|
||||
arRaf.current = 0;
|
||||
setArsAt((n) => n + 1);
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => () => cancelAnimationFrame(arRaf.current), []);
|
||||
const onAr = useCallback(
|
||||
(id: string, ar: number) => {
|
||||
if (!(ar > 0)) return;
|
||||
const held = wallAr.get(id);
|
||||
if (held !== undefined && Math.abs(held - ar) < 0.005) return;
|
||||
wallAr.set(id, ar);
|
||||
bumpArs();
|
||||
},
|
||||
[bumpArs]
|
||||
);
|
||||
const ars = useMemo(() => {
|
||||
let sum = 0;
|
||||
for (const ar of wallAr.values()) sum += ar;
|
||||
const fallback = wallAr.size ? sum / wallAr.size : WALL_AR;
|
||||
return listed.map((photo) => wallAr.get(photo.id) ?? fallback);
|
||||
// `arsAt` is the shape map having grown: the numbers are read off it and not
|
||||
// off the list, so nothing else here would make the shelf look again.
|
||||
}, [listed, arsAt]);
|
||||
// The width a row is laid in, and the shelf laid in it. `clientWidth` is the
|
||||
// wall's own width and the scrollbar is not part of it, so a row justified to it
|
||||
// meets the edge the reader sees.
|
||||
const wallRef = useRef<HTMLDivElement>(null);
|
||||
const wallWidth = useWallWidth(wallRef, view === 'grid');
|
||||
const layout = useMemo(
|
||||
() => wallLayout(dayStarts, dayHeads, ars, listed.length, wallWidth, WALL_STEPS),
|
||||
[dayStarts, dayHeads, ars, listed.length, wallWidth]
|
||||
);
|
||||
const wall = useWallWindow(wallRef, layout.rows, 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`). A run the
|
||||
// month changes at also carries the month's band, read off the shelf's own days
|
||||
// (`dayHeads`) rather than off the slice, which begins wherever the window does.
|
||||
const paintedBlocks = useMemo(() => {
|
||||
const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
|
||||
let i = Math.min(wall.from, listed.length);
|
||||
return dayRuns(painted, at).map((day) => {
|
||||
const head = dayHeads[dayAt(dayStarts, i)] === 1;
|
||||
i += day.photos.length;
|
||||
return { day, head };
|
||||
});
|
||||
}, [painted, listed, wall.from, dayStarts, dayHeads]);
|
||||
// land at all. The window is cut into the rows it draws, and the frames of those
|
||||
// rows are the slice the thumbnail cache is handed.
|
||||
// The same window, cut into the rows it draws: a wall is read by the day a frame
|
||||
// was shot, and the line above each run of them is that day. A row of the layout
|
||||
// is already what the DOM draws — a run of frames of one day, or the day's own
|
||||
// band — so the two are never two answers to the same question, and the row a
|
||||
// window starts in is a row the layout knows the top of (see `wallLayout`).
|
||||
const wallRows = useMemo(() => {
|
||||
const rows: { row: WallRow; at: number; photos: LibraryPhoto[]; head: boolean }[] = [];
|
||||
const last = Math.min(wall.to, layout.rows.length);
|
||||
for (let i = Math.min(wall.from, last); i < last; i++) {
|
||||
const row = layout.rows[i];
|
||||
const first = listed[row.from];
|
||||
rows.push({
|
||||
row,
|
||||
at: first ? first.taken || first.addedAt : 0,
|
||||
photos: row.to > row.from ? listed.slice(row.from, row.to) : [],
|
||||
head: row.band && dayHeads[row.day] === 1,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [layout, listed, wall.from, wall.to, dayHeads]);
|
||||
const painted = useMemo(() => {
|
||||
if (!wallRows.length) return [];
|
||||
const first = wallRows[0].row.from;
|
||||
return listed.slice(first, Math.max(first, wallRows[wallRows.length - 1].row.to));
|
||||
}, [wallRows, listed]);
|
||||
// The timeline's own cut of the shelf: a tick a year, and the months of it
|
||||
// under it, on the same three levels the wall is banded by. Read off the whole
|
||||
// shelf and not the window — the ruler stands for the roll, not for the screen.
|
||||
@@ -2100,36 +2161,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
}
|
||||
return marks;
|
||||
}, [listed, lang]);
|
||||
// The month the needle stands in: the month of the day band the eye is under,
|
||||
// written by the observer below. `''` before the wall has said anything, which
|
||||
// leaves the needle at the top of the ruler.
|
||||
const [eyeMonth, setEyeMonth] = useState('');
|
||||
useEffect(() => {
|
||||
const el = wall.ref.current;
|
||||
if (view !== 'grid' || !el || !timeMarks.length) return;
|
||||
// The band the eye is under, and not the first one in the DOM: the window
|
||||
// draws a couple of rows above the fold as well, and a needle that read the
|
||||
// first band drawn would stand two rows early. `-72%` off the bottom leaves
|
||||
// the top three tenths of the wall as the band of the eye.
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const month = (entry.target as HTMLElement).dataset.month;
|
||||
if (month) setEyeMonth(month);
|
||||
}
|
||||
},
|
||||
{ root: el, rootMargin: '0px 0px -72% 0px', threshold: 0 }
|
||||
);
|
||||
for (const section of el.querySelectorAll<HTMLElement>('.adm-day')) io.observe(section);
|
||||
return () => io.disconnect();
|
||||
}, [view, wall.ref, wall.from, wall.to, timeMarks.length]);
|
||||
const eyeAt = useMemo(() => {
|
||||
const mark = timeMarks.find((m) => m.kind === 'month' && m.key === eyeMonth);
|
||||
if (mark) return mark.at;
|
||||
const first = listed[0];
|
||||
return first ? first.taken || first.addedAt : 0;
|
||||
}, [timeMarks, eyeMonth, listed]);
|
||||
// The month the needle stands in: the row the eye is at, read off the shelf's
|
||||
// own layout — the wall says which row the top of its viewport falls in — and
|
||||
// not off the bands that happen to be drawn, which stand a couple of rows above
|
||||
// the fold as well. `''` on an empty shelf, which leaves the needle at the top.
|
||||
const eyePhoto = layout.rows.length ? listed[dayStarts[layout.rows[Math.min(wall.eye, layout.rows.length - 1)].day]] : undefined;
|
||||
const eyeAt = eyePhoto ? eyePhoto.taken || eyePhoto.addedAt : 0;
|
||||
const eyeMonth = eyeAt ? monthKey(eyeAt) : '';
|
||||
// Going where the ruler is pressed: the first day of that month is brought to
|
||||
// the top of the wall. The day's own top comes off the shelf's layout rather
|
||||
// than off its box — the day is not in the DOM, which is the whole reason the
|
||||
@@ -2137,17 +2175,16 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
// never drawn.
|
||||
const jumpToMonth = useCallback(
|
||||
(at: number) => {
|
||||
const el = wall.ref.current;
|
||||
const el = wallRef.current;
|
||||
if (!el || !listed.length) return;
|
||||
const key = monthKey(at);
|
||||
const k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
|
||||
if (k < 0) return;
|
||||
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
||||
const layout = wallLayout(dayStarts, dayHeads, listed.length, cols, wall.steps.current);
|
||||
if (!(layout.tops[k] >= 0)) return;
|
||||
el.scrollTop = Math.max(0, layout.tops[k] - 6);
|
||||
const row = layout.rows[dayRow(layout.rows, k)];
|
||||
if (!row) return;
|
||||
el.scrollTop = Math.max(0, row.top - WALL_EYE);
|
||||
},
|
||||
[listed, dayStarts, dayHeads, wall.ref, wall.steps]
|
||||
[listed, dayStarts, layout, wallRef]
|
||||
);
|
||||
// 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
|
||||
@@ -2221,17 +2258,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 not in the DOM to begin with — the wall only draws
|
||||
// the rows around its viewport — so a pass with no card goes to the row the
|
||||
// frame is drawn in, read off the shelf's own layout, and the pass after that
|
||||
// reads the card itself. 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.
|
||||
useEffect(() => {
|
||||
if (view !== 'grid') return;
|
||||
const id = centredRef.current;
|
||||
const el = wall.ref.current;
|
||||
const el = wallRef.current;
|
||||
if (!id || !el) return;
|
||||
const idx = listed.findIndex((p) => p.id === id);
|
||||
if (idx < 0) {
|
||||
@@ -2247,29 +2283,9 @@ 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, dayHeads, 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));
|
||||
}
|
||||
} else if (layout.rows.length) {
|
||||
const row = layout.rows[rowOf(layout.rows, idx)];
|
||||
el.scrollTop = Math.max(0, Math.round(row.top - (el.clientHeight - row.height) / 2));
|
||||
}
|
||||
if (--left > 0) requestAnimationFrame(settle);
|
||||
else centredRef.current = null;
|
||||
@@ -2281,7 +2297,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, layout, wallRef, 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).
|
||||
@@ -2323,7 +2339,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 = wallRef.current;
|
||||
const picked =
|
||||
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
|
||||
? lastSelectedId
|
||||
@@ -2633,13 +2649,14 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
// mint them), and the browser already keeps what it fetched for the day
|
||||
// `/api/immich/thumb` says it may — so the wall scrolls on and the visit after
|
||||
// draws without asking twice.
|
||||
const tile = (photo: LibraryPhoto, onPick?: () => void) => {
|
||||
const tile = (photo: LibraryPhoto, onPick?: () => void, onAr?: (ar: number) => void) => {
|
||||
const src = urls[photo.id] ?? (photo.remoteId ? remoteUrl('thumbnail', photo) : null);
|
||||
return src ? (
|
||||
<TileImg
|
||||
src={src}
|
||||
rot={photo.rot ?? 0}
|
||||
onPick={onPick}
|
||||
onAr={onAr}
|
||||
title={onPick ? t('lib.open') : undefined}
|
||||
/>
|
||||
) : (
|
||||
@@ -3243,99 +3260,101 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
<>
|
||||
{/* 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.
|
||||
The wall is read the way the phone's own grid reads a
|
||||
shelf and the way a cloud library does: the months down
|
||||
it, and inside a month the days, one under the other —
|
||||
the band of a day over the frames of that day, so a roll
|
||||
is scanned by the month it was shot. The rows the eye
|
||||
is not in stand as two spacers, which is what the ruler
|
||||
beside the wall is pressed against. */}
|
||||
is the rows around the eye. The rows stand at their own
|
||||
tops inside a body that holds the shelf's whole height —
|
||||
a height that comes off the layout and not off the DOM —
|
||||
so the shelf never moves under the reader, the frames at
|
||||
the top and the bottom of the window are never stood
|
||||
over, and the last row of the roll is a row the
|
||||
scrollbar can reach.
|
||||
A roll is read the way a contact sheet is: a day is a
|
||||
line with the frames of that day under it, packed into
|
||||
rows justified to the wall's own width, so the frames of
|
||||
a day stand together, meet both edges, and keep their
|
||||
own proportions. */}
|
||||
<div className="lib-wall-row">
|
||||
<div className="adm-grid" ref={wall.ref}>
|
||||
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||||
{paintedBlocks.map(({ day, head }) => (
|
||||
<section
|
||||
className="adm-day"
|
||||
key={day.key}
|
||||
data-key={`lib-day-${day.key}`}
|
||||
data-month={monthKey(day.at)}
|
||||
>
|
||||
{/* The month above the first day it holds, and the
|
||||
day above its frames. The day's own line still
|
||||
says the whole date — a card is read on its own
|
||||
once picked — but the month is named once. */}
|
||||
{head ? (
|
||||
<h2 className="adm-month-head" data-key={`lib-month-${monthKey(day.at)}`}>
|
||||
<span>{monthTitle(day.at, lang)}</span>
|
||||
</h2>
|
||||
) : null}
|
||||
<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()}
|
||||
>
|
||||
{[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)}
|
||||
<div className="adm-grid" ref={wallRef}>
|
||||
<div className="adm-wall-body" style={{ height: layout.height }}>
|
||||
{wallRows.map(({ row, at, photos, head }) => (
|
||||
<div
|
||||
className={`adm-row${row.band ? ' adm-band' : ''}`}
|
||||
key={row.band ? `h${row.day}` : `r${row.day}-${row.from}`}
|
||||
data-key={row.band ? `lib-day-${dayKey(at)}` : undefined}
|
||||
data-day={dayKey(at)}
|
||||
style={{ top: row.top, height: row.height }}
|
||||
>
|
||||
{row.band ? (
|
||||
<>
|
||||
{/* The month above the first day it holds, and
|
||||
the day over its frames: the month is
|
||||
named once, and the day under it is the
|
||||
line its frames are read by. */}
|
||||
{head ? (
|
||||
<h2 className="adm-month-head" data-key={`lib-month-${monthKey(at)}`}>
|
||||
<span>{monthTitle(at, lang)}</span>
|
||||
</h2>
|
||||
) : null}
|
||||
<h3 className="adm-day-head">
|
||||
<i className="adm-day-dot" aria-hidden="true" />
|
||||
<span>{dayLabel(at, lang)}</span>
|
||||
</h3>
|
||||
</>
|
||||
) : (
|
||||
photos.map((photo, j) => (
|
||||
<article
|
||||
className={`lib-tile${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
key={photo.id}
|
||||
data-key={`lib-photo-${photo.id}`}
|
||||
style={{ width: ars[row.from + j] * row.height }}
|
||||
title={`${photo.name} · ${new Date(photo.taken || photo.addedAt).toLocaleDateString()} · ${t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}`}
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onDoubleClick={() => {
|
||||
setUp(photo.id);
|
||||
scrolledFrameRef.current = null;
|
||||
setView('preview');
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo, undefined, (ar) => onAr(photo.id, ar))}
|
||||
{/* The score over 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. It stands on the frame the pointer
|
||||
is on, and stays on a frame that has a
|
||||
score. The click stops at the star — a
|
||||
score is not a pick, and the tile's own
|
||||
click is what selects the frame. */}
|
||||
<div
|
||||
className={`lib-stars${(photo.star ?? 0) > 0 ? ' on' : ''}`}
|
||||
data-key={`lib-stars-${photo.id}`}
|
||||
role="group"
|
||||
aria-label={t('lib.rating')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
★
|
||||
</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}
|
||||
</div>
|
||||
{[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>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* The shelf's own time, down its right edge: a year is the
|
||||
tick that is named, the months of it are the ticks under
|
||||
it, and the band the eye is in is the needle. */}
|
||||
@@ -4063,10 +4082,49 @@ function useTurned(src: string | null, deg: number): string | null {
|
||||
// turn the stage does, so a frame the reader has stood upright is not lying on
|
||||
// its side two rows down. One canvas per turned frame and none at all for the
|
||||
// rest — a tile at 0 hands its URL straight on.
|
||||
function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick?: () => void; title?: string }) {
|
||||
return <img src={useTurned(src, rot) ?? src} alt="" loading="lazy" onClick={onPick} title={title} />;
|
||||
// The picture is also the one thing that knows how wide a frame is against its
|
||||
// own height, which is what a justified row is laid out with: `onAr` is handed
|
||||
// the ratio on the frame the browser decodes it, and the wall lays the shelf out
|
||||
// again with it (`onAr` in the catalogue).
|
||||
function TileImg({
|
||||
src,
|
||||
rot,
|
||||
onPick,
|
||||
onAr,
|
||||
title,
|
||||
}: {
|
||||
src: string;
|
||||
rot: number;
|
||||
onPick?: () => void;
|
||||
onAr?: (ar: number) => void;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={useTurned(src, rot) ?? src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={onPick}
|
||||
title={title}
|
||||
onLoad={
|
||||
onAr
|
||||
? (e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) onAr(img.naturalWidth / img.naturalHeight);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The shape of the frames the wall has been shown, by the frame's own id, for the
|
||||
// length of the visit: the catalogue cannot be asked for one — a scan reads a date
|
||||
// and a size off a file, not a width and a height — and a row is justified to the
|
||||
// wall's width, which is a sum of shapes. Kept past the row that measured it, so a
|
||||
// frame scrolled out of the wall and back is not measured again.
|
||||
const wallAr = new Map<string, number>();
|
||||
|
||||
// One object URL per tile, made when the row does and dropped with it: a folder
|
||||
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
|
||||
const GLOBAL_URL_MAX = 600;
|
||||
@@ -4222,179 +4280,119 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
|
||||
}
|
||||
|
||||
|
||||
// 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[],
|
||||
heads: 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>({ head: MONTH_BAND, band: DAY_BAND, 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(() => {
|
||||
// The width a row of the wall is laid in: the wall's own `clientWidth`, which is
|
||||
// the box inside the scrollbar and not the one past it, so a row justified to it
|
||||
// meets the edge the reader sees. Measured and not assumed — the pane is resized
|
||||
// by the window and by the tree beside it, and a shelf laid in a width it no
|
||||
// longer has is a row that runs past the wall or stops short of it.
|
||||
function useWallWidth(ref: RefObject<HTMLDivElement>, on: boolean): number {
|
||||
const [width, setWidth] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (!on) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const read = () => setWidth((prev) => (prev === el.clientWidth ? prev : el.clientWidth));
|
||||
read();
|
||||
const ro = new ResizeObserver(read);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [ref, on]);
|
||||
return width;
|
||||
}
|
||||
|
||||
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
|
||||
// either side. The rows not drawn are not in the DOM at all — the shelf's own
|
||||
// height stands in the body above them, off the layout (`wallLayout`), so the
|
||||
// scrollbar still spans the roll — 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.
|
||||
//
|
||||
// The window is reported in rows and not as a slice of the shelf, because a row is
|
||||
// what the wall draws and the row a scroll position falls in is a question the
|
||||
// layout answers (`rowAt`). `eye` is that same row at the top of the viewport,
|
||||
// which is the row the ruler's needle reads off.
|
||||
function useWallWindow(
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
rows: WallRow[],
|
||||
on: boolean
|
||||
): { from: number; to: number; eye: number } {
|
||||
const [win, setWin] = useState({ from: 0, to: 0 });
|
||||
const [eye, setEye] = useState(0);
|
||||
// What was last written, because the reader of a `setWin` in a layout effect is
|
||||
// not the state: a set made from one renders again whether or not the value
|
||||
// moved, and this one is measured after every render — so a set it did not need
|
||||
// is the screen's own render loop (React #185, "Maximum update depth exceeded").
|
||||
// The refs are the guard: nothing is set that the wall already stands at.
|
||||
const winRef = useRef(win);
|
||||
const eyeRef = useRef(0);
|
||||
const measureRef = useRef<() => void>(() => {});
|
||||
useLayoutEffect(() => {
|
||||
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.
|
||||
// The wall that is up, and not 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 box it replaced
|
||||
// is a window read off nothing.
|
||||
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.
|
||||
// A wall that is not standing has no box: the library is `display: none`
|
||||
// while the studio is up, and every scroll read off it is nought — which
|
||||
// threw the shelf back to its top every time the studio handed back to it.
|
||||
// It is read again 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 });
|
||||
if (!rows.length) {
|
||||
if (winRef.current.from || winRef.current.to) {
|
||||
winRef.current = { from: 0, to: 0 };
|
||||
setWin(winRef.current);
|
||||
}
|
||||
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 one step left to read off the drawn days: a card and the grid's own
|
||||
// gap — the two bands are heights the style sheet fixes (`MONTH_BAND`,
|
||||
// `DAY_BAND`), so a shelf is measured exactly from the first card on and
|
||||
// the spacers do not move with the window. 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 card = sections[0]?.querySelector<HTMLElement>('.adm-card');
|
||||
if (card) {
|
||||
const row = card.offsetHeight + WALL_GAP;
|
||||
if (row > 0) {
|
||||
colsRef.current = cols;
|
||||
stepsRef.current = { head: MONTH_BAND, band: DAY_BAND, row, gap: WALL_GAP };
|
||||
}
|
||||
}
|
||||
// A window that is nowhere on the shelf — a shorter roll under it, or the
|
||||
// first paint, before the catalogue has landed — stands nowhere: it opens at
|
||||
// the top. Written off the shelf's own rows and not off the window before it,
|
||||
// which is what made the reset a window the window fed.
|
||||
if (winRef.current.from >= rows.length || winRef.current.to > rows.length) {
|
||||
winRef.current = { from: 0, to: 0 };
|
||||
setWin(winRef.current);
|
||||
}
|
||||
const from = Math.max(0, rowAt(rows, now.scrollTop) - WALL_ROWS);
|
||||
const to = Math.min(rows.length, rowAt(rows, now.scrollTop + now.clientHeight) + 1 + WALL_ROWS);
|
||||
if (winRef.current.from !== from || winRef.current.to !== to) {
|
||||
winRef.current = { from, to };
|
||||
setWin({ from, to });
|
||||
}
|
||||
const at = Math.max(0, Math.min(rows.length - 1, rowAt(rows, now.scrollTop + WALL_EYE)));
|
||||
if (eyeRef.current !== at) {
|
||||
eyeRef.current = at;
|
||||
setEye(at);
|
||||
}
|
||||
// And a run with no step 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.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, heads, 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);
|
||||
// Read here as well as after every render: this runs before the first paint,
|
||||
// so the wall opens on the rows the eye is in and not on a screen of nothing.
|
||||
measure();
|
||||
el.addEventListener('scroll', measure, { passive: true });
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
measureRef.current = () => {};
|
||||
el.removeEventListener('scroll', onOutside);
|
||||
el.removeEventListener('scroll', measure);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [count, dayStarts, heads, on]);
|
||||
}, [rows, ref, on]);
|
||||
useLayoutEffect(() => {
|
||||
// And again after every render: the shelf is laid out again when the wall is
|
||||
// resized, when a frame's shape lands, and when the roll itself changes, and
|
||||
// none of the three is a thing this effect can see coming. What is set is only
|
||||
// what the wall is not already at (above).
|
||||
measureRef.current();
|
||||
});
|
||||
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
|
||||
return { from: win.from, to: win.to, eye };
|
||||
}
|
||||
|
||||
|
||||
// One tick of the wall's own time: a year, or a month of one. Built off the
|
||||
// shelf and not off a calendar — a month the roll holds nothing in is a month
|
||||
// with no tick, which is what keeps the ruler the length of the roll.
|
||||
|
||||
@@ -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;
|
||||
@@ -1312,44 +1309,36 @@ form[data-key='immich-modal'] {
|
||||
.adm-note { margin: 12px 0; }
|
||||
.adm-sub { margin: 0 0 14px; }
|
||||
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
/* A day of the wall: the band that names it and the rows under it. It spans the
|
||||
whole grid — the wall is one column of days, not a day per cell — and the rows
|
||||
inside are the wall's own grid again, so the cards keep the columns they had
|
||||
when the wall drew them flat. */
|
||||
.adm-day { grid-column: 1 / -1; }
|
||||
.adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
/* The month's own band, over the first day it holds: a heavier line than a day's,
|
||||
and the only line of the wall that is not a date — so a shelf is read by the
|
||||
month it was shot and only then by the day. Its height is fixed (54px, the
|
||||
`MONTH_BAND` the wall measures its shelf with): the wall stands in spacers for
|
||||
the months it has not drawn, and a band that grew with its own text is a shelf
|
||||
whose end moves. */
|
||||
month it was shot and only then by the day. Its height is fixed (44px, the
|
||||
`MONTH_BAND` the wall lays its shelf out with): the shelf's whole height is
|
||||
built out of it before a frame is drawn, and a band that grew with its own text
|
||||
is a shelf whose end moves. */
|
||||
.adm-month-head {
|
||||
height: 54px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
font-size: 19px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
/* A day's own line under it, at the height the wall counts it in (`DAY_BAND`). */
|
||||
/* The day a run of frames was shot on, over the frames of it: the small line and
|
||||
the dot it is read by, at the height the wall lays it in (`DAY_BAND`). */
|
||||
.adm-day-head {
|
||||
height: 28px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim, #6b7280);
|
||||
}
|
||||
/* The rule that carries the eye from the date to the edge of the wall. */
|
||||
.adm-day-line { flex: 1 1 auto; height: 1px; background: var(--border); }
|
||||
.adm-day-dot { flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.7; }
|
||||
.adm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1498,12 +1487,56 @@ form[data-key='immich-modal'] {
|
||||
.lib-root .adm-stage > .chip-row { display: flex; justify-content: center; }
|
||||
}
|
||||
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
|
||||
the right edge is what reaches the frames below. */
|
||||
/* `align-content: start`, because the grid is stretched to the stage: a wall with
|
||||
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; }
|
||||
the right edge is what reaches the frames below. It is a plain scroll box and
|
||||
not a grid of its own: the rows inside it are laid out at the tops the shelf's
|
||||
own layout gives them (see `wallLayout` in ui/dateGroups.ts), and the box's
|
||||
height is that layout's, so the scrollbar spans the roll and not the screen. */
|
||||
.lib-root .adm-grid { display: block; position: relative; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0; overflow-anchor: none; }
|
||||
/* The shelf's whole height, which the rows are laid in. The rows are out of the
|
||||
flow — absolute, at the top the layout gave each of them — so this element is
|
||||
what the scrollbar measures, and it stands the layout's height exactly: a height
|
||||
read off the rows drawn is a scrollbar that moves with the window, and the last
|
||||
frames of a roll are then frames nobody can scroll to. */
|
||||
.adm-wall-body { position: relative; width: 100%; }
|
||||
/* A row of the wall: a run of frames of one day, meeting both edges — the widths
|
||||
come off the layout, which packed them to this wall — with the gap a contact
|
||||
sheet leaves (the `WALL_GAP` in Library.tsx, the same 3px). */
|
||||
.adm-row { position: absolute; left: 0; right: 0; display: flex; gap: 3px; }
|
||||
/* The month's band and the day's, which are rows of the shelf too, and stand one
|
||||
over the other inside the band row. */
|
||||
.adm-row.adm-band { display: block; }
|
||||
/* A frame on the wall: the picture and nothing else. The wall is read as one shelf
|
||||
of photographs, so a tile carries no frame of its own, no padding and no caption
|
||||
— the name and the date are on the tile's own title — and the picture fills the
|
||||
box the layout gave it, cropped to it rather than letter-boxed, since the box is
|
||||
the frame's own proportions to within a pixel or two. */
|
||||
.lib-tile {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-sunken);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lib-tile img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.lib-tile .lib-noimg { width: 100%; height: 100%; aspect-ratio: auto; border-radius: 0; }
|
||||
.lib-tile.selected { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
/* The score, over the picture: on the frame the pointer is on, and left standing
|
||||
on a frame that has one. The click stops at the star, so the tile's own click —
|
||||
the pick — is not what a score costs. */
|
||||
.lib-tile .lib-stars {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
||||
opacity: 0;
|
||||
}
|
||||
.lib-tile:hover .lib-stars,
|
||||
.lib-tile .lib-stars.on { opacity: 1; }
|
||||
.lib-tile .lib-star { font-size: 12px; padding: 1px 2px; }
|
||||
/* The wall and the ruler beside it: the wall takes what is left, the ruler is
|
||||
the fixed strip down the right edge. The row is the stage's own middle track —
|
||||
it carries the height the grid used to carry alone, since the grid now shares
|
||||
@@ -1521,7 +1554,7 @@ form[data-key='immich-modal'] {
|
||||
/* A year's own field, the months of it standing inside it: what makes the ruler
|
||||
read by the year first and the month under it after. */
|
||||
.lib-ruler-band { position: absolute; left: 0; right: 0; background: var(--bg-elev); border-radius: 3px; }
|
||||
.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: var(--border); }
|
||||
.lib-ruler-tick { position: absolute; left: 0; width: 10px; height: 1px; background: color-mix(in srgb, var(--text-dim) 30%, transparent); }
|
||||
.lib-ruler-year { width: 100%; height: 2px; background: var(--text-dim); }
|
||||
.lib-ruler-year i { position: absolute; top: 2px; left: 14px; font-size: 9px; font-style: normal; font-weight: 700; letter-spacing: 0.06em; color: var(--text-dim); }
|
||||
.lib-ruler-tick.on { width: 22px; background: var(--accent); }
|
||||
|
||||
@@ -46,203 +46,123 @@ export function monthTitle(timestamp: number, lang: Lang): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
export type DayRun<T> = { key: string; at: number; photos: T[] };
|
||||
// The steps a shelf is stacked in: the month's own band (`head`) and the day's
|
||||
// (`band`) above the frames of a day, the height a row of frames is justified to
|
||||
// (`target`), and what stands between two rows (`gap`). The bands are heights the
|
||||
// style sheet fixes (`MONTH_BAND` / `DAY_BAND` in Library.tsx are the same two
|
||||
// numbers), and `target` is a number of the wall's own, so the shelf's whole
|
||||
// height is known before a frame is drawn — which is what lets the scrollbar
|
||||
// reach the frames nobody has scrolled to yet.
|
||||
export type WallSteps = { head: number; band: number; target: number; gap: number };
|
||||
|
||||
// The runs of one day, cut off the list as it stands rather than sorted into
|
||||
// days of their own: the wall carries the reader's sort, and a shelf ordered by
|
||||
// name is a shelf whose days interleave — a header is put where the day changes,
|
||||
// and the order of the frames is never touched.
|
||||
export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
|
||||
const runs: DayRun<T>[] = [];
|
||||
for (const photo of photos) {
|
||||
const time = at(photo);
|
||||
const key = dayKey(time);
|
||||
const last = runs[runs.length - 1];
|
||||
if (last?.key === key) last.photos.push(photo);
|
||||
else runs.push({ key, at: time, photos: [photo] });
|
||||
}
|
||||
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 steps a shelf is stacked in, and what the grid leaves between them: a day
|
||||
// opens with a band of `head` when it is the first day of its month, then a band
|
||||
// of `band`, then 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 all of them, and a
|
||||
// shelf whose days do not come out even over the columns lays them 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.
|
||||
//
|
||||
// The bands are heights the style sheet fixes (`MONTH_BAND` / `DAY_BAND` in
|
||||
// Library.tsx are the same two numbers), so the shelf's own height is known
|
||||
// before a card is drawn: only `row` — a card and the grid's gap — is measured,
|
||||
// and only once per column count.
|
||||
export type WallSteps = { head: number; 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. `heads[k]` is 1 on a day that opens a month, which is what the
|
||||
// wall draws above it.
|
||||
export type WallLayout = {
|
||||
steps: WallSteps;
|
||||
cols: number;
|
||||
rows: number;
|
||||
order: number[];
|
||||
heads: number[];
|
||||
tops: number[];
|
||||
ends: number[];
|
||||
// One row of the shelf: a run of frames of one day, or the day's own band over
|
||||
// them. `top` and `height` are the row's box in the shelf, `from`/`to` the frames
|
||||
// it draws (a band draws none), and `day` the day it belongs to.
|
||||
export type WallRow = {
|
||||
day: number;
|
||||
band: boolean;
|
||||
top: number;
|
||||
height: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type WallLayout = { rows: WallRow[]; height: number };
|
||||
|
||||
// What a frame is worth before its own picture has said: the wall lays a shelf out
|
||||
// before a file has been read, and a row measured at the average rectangle is a
|
||||
// row that only moves by as much as the frames in it are unlike it.
|
||||
export const WALL_AR = 1.5;
|
||||
|
||||
// The shelf measured as rows of pictures, the way a contact sheet is laid out and
|
||||
// not the way a grid of cards is: a day opens with its band, and under it its
|
||||
// frames are packed into rows that are justified to the wall's own width at
|
||||
// `target` — a row holds frames until it is at least as wide as the wall, and is
|
||||
// then stretched (or shrunk) to exactly that width, so every row in a day shares
|
||||
// its top and its height and the frames keep their own proportions inside it. A
|
||||
// day's last row is left justified to nothing: it stands at `target` and as wide
|
||||
// as its frames come out, which is what tells the eye where one day ends and the
|
||||
// next begins.
|
||||
//
|
||||
// Laid out here rather than read off the DOM so the shelf's whole height is known
|
||||
// from the list: the wall draws only the rows around the eye, and a height it had
|
||||
// to read off the rows it drew is a scrollbar that shrinks with the window — the
|
||||
// frames under the fold are then frames nobody can scroll to, and the ones at the
|
||||
// top of the window are hidden behind a shelf that moved.
|
||||
export function wallLayout(
|
||||
dayStarts: number[],
|
||||
heads: number[],
|
||||
ars: number[],
|
||||
count: number,
|
||||
cols: number,
|
||||
width: number,
|
||||
steps: WallSteps
|
||||
): WallLayout {
|
||||
const order = bandRows(dayStarts, count, cols);
|
||||
const tops: number[] = [];
|
||||
const ends: number[] = [];
|
||||
const rows: WallRow[] = [];
|
||||
const wall = Math.max(1, width);
|
||||
const gap = steps.gap;
|
||||
let y = 0;
|
||||
for (let k = 0; k < dayStarts.length; k++) {
|
||||
const frames = (k + 1 < dayStarts.length ? dayStarts[k + 1] : count) - dayStarts[k];
|
||||
// The last row of a day leaves no gap under it: the grid inside the day ends
|
||||
// with the card, and the gap between two days is the one the wall's own grid
|
||||
// draws between their boxes (counted once, past the block).
|
||||
const height = (heads[k] ? steps.head : 0) + steps.band + Math.ceil(frames / cols) * steps.row - steps.gap;
|
||||
tops.push(y);
|
||||
ends.push(y + height);
|
||||
y += height + steps.gap;
|
||||
const start = dayStarts[k];
|
||||
const end = k + 1 < dayStarts.length ? dayStarts[k + 1] : count;
|
||||
const band = (heads[k] ? steps.head : 0) + steps.band;
|
||||
rows.push({ day: k, band: true, top: y, height: band, from: start, to: start });
|
||||
y += band + gap;
|
||||
let i = start;
|
||||
while (i < end) {
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
// One frame in, then one more for as long as the row is still narrower than
|
||||
// the wall at `target`: what the last frame added is what takes it past.
|
||||
while (i + n < end && (n === 0 || sum * steps.target + (n - 1) * gap < wall)) {
|
||||
const ar = ars[i + n];
|
||||
sum += ar > 0 ? ar : WALL_AR;
|
||||
n++;
|
||||
}
|
||||
const short = sum * steps.target + (n - 1) * gap < wall;
|
||||
const height = short ? steps.target : (wall - (n - 1) * gap) / sum;
|
||||
rows.push({ day: k, band: false, top: y, height, from: i, to: i + n });
|
||||
y += height + gap;
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
return {
|
||||
steps,
|
||||
cols,
|
||||
rows: order[dayStarts.length],
|
||||
order,
|
||||
heads,
|
||||
tops,
|
||||
ends,
|
||||
height: ends.length ? ends[ends.length - 1] : 0,
|
||||
};
|
||||
return { rows, height: rows.length ? y - gap : 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 {
|
||||
// The row a scroll position stands in: the last row whose own top is above it.
|
||||
export function rowAt(rows: WallRow[], y: number): number {
|
||||
if (!rows.length || y <= 0) return 0;
|
||||
let lo = 0;
|
||||
let hi = Math.max(0, order.length - 2);
|
||||
let hi = rows.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (order[mid] <= row) lo = mid;
|
||||
if (rows[mid].top <= y) 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 — the
|
||||
// month's own band above it on the first day of a month — and the frame rows
|
||||
// under it start where the bands' own height ends.
|
||||
export function rowTop(layout: WallLayout, row: number): number {
|
||||
if (!layout.tops.length) return 0;
|
||||
const k = dayOfRow(layout.order, row);
|
||||
const bands = (layout.heads[k] ? layout.steps.head : 0) + layout.steps.band;
|
||||
return row <= layout.order[k]
|
||||
? layout.tops[k]
|
||||
: layout.tops[k] + bands + (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;
|
||||
// And the other way: the row a frame is drawn in — the first row whose own run
|
||||
// reaches it, bands drawn none and passed over.
|
||||
export function rowOf(rows: WallRow[], index: number): number {
|
||||
let lo = 0;
|
||||
let hi = layout.tops.length - 1;
|
||||
let hi = Math.max(0, rows.length - 1);
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (layout.tops[mid] <= y) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
const bands = (layout.heads[lo] ? layout.steps.head : 0) + layout.steps.band;
|
||||
const rel = y - layout.tops[lo];
|
||||
if (rel < bands) return layout.order[lo];
|
||||
const under = Math.floor((rel - bands) / 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;
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (rows[mid].to > index) hi = mid;
|
||||
else lo = 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;
|
||||
// The row a day opens with, which is its own band: where the ruler takes the wall
|
||||
// when a month is pressed.
|
||||
export function dayRow(rows: WallRow[], day: number): number {
|
||||
let lo = 0;
|
||||
let hi = dayStarts.length - 1;
|
||||
let hi = Math.max(0, rows.length - 1);
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (order[mid] <= row) lo = mid;
|
||||
else hi = mid - 1;
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (rows[mid].day < day || (rows[mid].day === day && !rows[mid].band)) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
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);
|
||||
return lo;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user