web: fold a folder's rows without folding its frames

This commit is contained in:
2026-09-28 19:34:37 +07:00
parent 2b45419ec4
commit 2c78ff80e8
3 changed files with 81 additions and 6 deletions
+22
View File
@@ -229,6 +229,28 @@ step(
JSON.stringify(nodes)
);
// A row with children folds its subtree on the same click that opens it, and the
// frames it holds stay in the strip — the tree narrows, the pictures do not.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, {
timeout: 10_000,
polling: 50,
});
const foldedThumbs = await page.$$eval(thumbs, (els) => els.length);
const foldedMeta = await page.locator('[data-key="lib-stage-meta"]').innerText();
const shut = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded');
step(
'a folder folds its subtree and keeps its frames in the strip',
foldedThumbs === 3 && shut === 'false' && /P1010256/.test(foldedMeta),
`${foldedThumbs} tiles, aria-expanded=${shut}, stage ${foldedMeta.split('\n')[0]}`
);
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, {
timeout: 10_000,
polling: 50,
});
step('the same click draws it open again', true, '3 rows');
// 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 === 2, thumbs, { timeout: 30_000 });
+43 -5
View File
@@ -26,9 +26,8 @@ import {
// thumbnails, and the RAW itself is read only when a frame is opened in the
// studio (`/app?lib=<id>`).
//
// ponytail: the tree does not collapse, and there is no search, rating or flag.
// A roll of date folders is three levels deep at most; add a collapsing node or
// a filter once the column stops being scrollable in practice.
// ponytail: no search, rating or flag. A roll of date folders is three levels
// deep at most; add a filter once the column stops being scrollable in practice.
interface Node {
// The tree's own key: a folder name, or `folder/sub/dir`.
key: string;
@@ -73,6 +72,9 @@ export function Library() {
// views (the frame, or every thumbnail of the open node) is on. The open node
// is picked up where the last visit left it.
const [node, setNode] = useState<string | null>(() => remembered(NODE_KEY));
// Rows whose children are folded away. A collapse is a way of looking at the
// tree, not a property of the roll, so it is not remembered between visits.
const [folded, setFolded] = useState<Set<string>>(() => new Set());
const [up, setUp] = useState<string | null>(null);
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [colWidth, setColWidth] = useState(() => {
@@ -285,6 +287,24 @@ export function Library() {
return out;
}, [folders, photos]);
// A row with anything under it is the one that folds: its key is the prefix of
// its children's, in the path order the rows come in.
const parents = useMemo(
() => new Set(nodes.filter((n) => n.depth > 0).map((n) => n.key.slice(0, n.key.lastIndexOf('/')))),
[nodes]
);
// What the column draws: a folded row takes its whole subtree with it.
const rows = useMemo(
() =>
nodes.filter((n) => {
const parts = n.key.split('/');
for (let i = 1; i < parts.length; i++) if (folded.has(parts.slice(0, i).join('/'))) return false;
return true;
}),
[nodes, folded]
);
// The folder the open node belongs to: its name titles the column, and its
// handle is what the menu acts on.
const root = folders.find((f) => f.name === rootName) ?? null;
@@ -423,11 +443,12 @@ export function Library() {
<b className="lib-col-title" data-key="lib-root-name" title={root?.label || root?.name}>
{root?.label || root?.name}
</b>
{nodes.map((n) => {
{rows.map((n) => {
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
// A roll being read says so on its own row: while a scan runs
// the toolbar's progress line is nowhere near the eye.
const scanning = !!folder && progress?.folder === folder.name;
const under = parents.has(n.key);
return (
<button
key={n.key}
@@ -436,9 +457,22 @@ export function Library() {
data-key={`lib-node-${n.key}`}
aria-current={node === n.key}
aria-busy={scanning}
aria-expanded={under ? !folded.has(n.key) : undefined}
title={t('lib.nodeHint')}
style={{ paddingLeft: 9 + n.depth * 12 }}
onClick={() => setNode(n.key)}
// A row with children opens on the folder and folds it in
// the same click: the frames below stay in the strip, only
// the rows go.
onClick={() => {
setNode(n.key);
if (!under) return;
setFolded((prev) => {
const next = new Set(prev);
if (next.has(n.key)) next.delete(n.key);
else next.add(n.key);
return next;
});
}}
// A folder's own menu: rename, scan again, forget. All three
// act on the folder that was picked, so a right click on a
// subfolder row brings up its roll's menu. Shift+F10 raises
@@ -454,6 +488,10 @@ export function Library() {
});
}}
>
<span
className={`lib-caret${under ? (folded.has(n.key) ? '' : ' open') : ' leaf'}`}
aria-hidden="true"
/>
<span className="adm-album-name">{n.label}</span>
{scanning ? <span className="lib-spin" aria-hidden="true" /> : null}
<span className="adm-album-count">{n.count}</span>
+16 -1
View File
@@ -1033,7 +1033,22 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
text-overflow: ellipsis;
white-space: nowrap;
}
.lib-root .adm-album { padding: 6px 8px; gap: 6px; }
.lib-root .adm-album { padding: 6px 8px; gap: 6px; justify-content: flex-start; }
.lib-root .adm-album-name { flex: 1 1 auto; min-width: 0; }
/* The caret column: pointing right where a row's children are folded away, down
where they are drawn, and empty on a leaf so names at one depth line up. */
.lib-caret {
flex: 0 0 auto;
width: 0;
height: 0;
border-left: 4.5px solid currentColor;
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
opacity: 0.5;
transition: transform 0.15s ease;
}
.lib-caret.open { transform: rotate(90deg); }
.lib-caret.leaf { border-left-color: transparent; }
/* A roll being read turns a ring on its own row. */
.lib-spin {
flex: 0 0 auto;