web: scan the whole roll, title and size the column, reopen where you left

The tree column now walks every folder under the one you picked instead of
its first level, so a roll of dated folders inside dated folders is walked to
the bottom. A folder being read turns a ring on its own row, the column is
titled with the folder the tree belongs to, and a divider beside it drags the
width, which is remembered along with the folder the screen was left on.
This commit is contained in:
2026-09-28 18:53:07 +07:00
parent 4c65923eff
commit 2b45419ec4
5 changed files with 247 additions and 54 deletions
+67 -16
View File
@@ -51,6 +51,9 @@ const sampleServer = createServer(async (req, res) => {
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
// A beat per frame: the catalogue reads two files faster than a poll, and the
// ring that says "this roll is being read" needs a window to be caught in.
await new Promise((resolve) => setTimeout(resolve, 250));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
@@ -104,23 +107,24 @@ await context.addInitScript(
return handle;
};
// The picked folder holds `names` at its top level and the subfolders in
// `subs` — a roll really is a folder of dated directories, which is what the
// tree column walks.
const dirHandle = (label, files, dirs) => {
// `subs`, each `{ files, dirs }` — a roll really is a folder of dated
// directories inside dated directories, which is what the tree column walks.
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: {
value: () =>
[...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][
Symbol.iterator
](),
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator](),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
const root = () => dirHandle(folder, names, subs);
const root = () => dirHandle(folder, { files: names, dirs: subs });
window.showDirectoryPicker = async () => root();
const revive = (row) => {
@@ -145,7 +149,14 @@ await context.addInitScript(
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' }
{
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
names: [JPG],
// One frame at the top, one a folder down, one two folders down: the walk has
// to reach the bottom of the roll, not just its first level.
subs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } } },
folder: 'CheckRoll',
}
);
const page = await context.newPage();
@@ -178,10 +189,23 @@ const catalogue = () =>
// --- the run ----------------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
// A folder that is being read says so on its own row, not only in the toolbar.
const ringed = await page
.waitForFunction(
() => {
const row = document.querySelector('.adm-album[aria-busy="true"]');
return row?.querySelector('.lib-spin') ? row.dataset.key : null;
},
null,
{ timeout: 10_000, polling: 50 }
)
.then((handle) => handle.jsonValue())
.catch(() => null);
step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed));
const thumbs = '[data-key^="lib-thumb-"]';
await page.waitForSelector(thumbs, { timeout: 120_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 });
step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${RAW}`);
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 });
step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${RAW}`);
const indexed = await catalogue();
for (const name of [JPG, RAW]) {
@@ -195,22 +219,27 @@ const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
);
step(
'tree lists the folder and its subfolder',
nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026',
'the tree draws the roll as folders under folders',
JSON.stringify(nodes) ===
JSON.stringify([
['lib-node-CheckRoll', '3'],
['lib-node-CheckRoll/2026', '2'],
['lib-node-CheckRoll/2026/04', '1'],
]),
JSON.stringify(nodes)
);
// Opening the subfolder redraws the strip and the frame above it.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 30_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 });
const staged = await page.locator('[data-key="lib-stage-meta"]').innerText();
step('the open subfolder draws its own strip and frame', /P1010256\.RW2/.test(staged), staged.replace(/\n/g, ' · '));
step('the open subfolder draws its own strip and frame', /P1010256\.(RW2|JPG)/.test(staged), staged.replace(/\n/g, ' · '));
// The switch: the middle becomes every thumbnail of the open node at once.
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
step('the grid view draws a card per frame', cards.length === 1, JSON.stringify(cards));
step('the grid view draws a card per frame', cards.length === 2, JSON.stringify(cards));
await page.click('[data-key="lib-view-preview"]');
await page.waitForSelector(thumbs, { timeout: 30_000 });
@@ -239,7 +268,7 @@ step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null)
const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
step(
'no FOLDERS heading, the count on the folder row',
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '2',
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '3',
`${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px`
);
step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`);
@@ -258,6 +287,11 @@ step(
(await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null,
await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)
);
step(
'the column is titled with the folder the tree belongs to',
(await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A',
await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)
);
// The empty part of the column is where one more folder comes from.
await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } });
@@ -267,6 +301,23 @@ step('a right click on the empty column offers a folder', blank.join(',') === 'l
await page.keyboard.press('Escape');
step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null);
// The divider drags the column, and what the visit leaves behind — the width the
// column was left at and the folder that was open — is what the next visit opens
// with. The right click on the empty column answered with a folder, not with a
// folder opened, so the open node is still the subfolder chosen above.
const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox();
await page.mouse.move(gripBox.x + 4, gripBox.y + 120);
await page.mouse.down();
await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 });
await page.mouse.up();
const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
await page.reload();
await page.waitForSelector(thumbs, { timeout: 120_000 });
const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key);
step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`);
step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where);
// One tap on a thumbnail raises the frame, and the button under it hands it to
// the studio. The RAW is the harder half — it has to be read back off the
// (stand-in) handle and developed — so it is the one that gets opened.