From c1ee2cf30a8f2c56ada358b30667fb5875067267 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 21:03:30 +0700 Subject: [PATCH] web: fold the whole roll from the head of the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roll read to the bottom of its dates is a long column of indented rows, and the only way to shut it was one row at a time — the click that folds a row also opens it, which is the right trade for reading and a poor one for putting away. The column's own name is now the whole tree's control: a right click there opens a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row that has something under it at once — the root's own row included, so the tree comes back to one line per picked folder and the subfolder two deep is away with the rest. The menu is the folder menu's shape at a third subject rather than a second menu: one `root` flag on the state that already knows where a right click landed, and the same Escape, same click-away, same clamp to the window edge. The frames do not move with the rows: folding is a way of looking at the tree, and the strip already reads the open folder, which the fold leaves alone. The name keeps reading as the folder it belongs to — the folder's label, with the hint appended to its title — and the item is disabled rather than hidden when nothing in the column has children, so a fresh folder does not open a menu with a dead line in it. Verified: library-check.mjs — 34 steps, all passed, 2 new: a right click on the root name offers exactly [lib-menu-collapse] while the name still reads "Roll A", and one click takes the fully open roll (4 rows: the picked folder, its subfolder, the subfolder under that, and one holding no frame) to a single row. The fold is measured against the ruler of opening the root back up: the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty — CheckRoll/2026/04 is still shut, which is what says the deep row went with the fold and not just the root. The strip held its 2 tiles throughout, and the folder the visit was on (lib-node-CheckRoll/2026) is what the reload below still reopens on. scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed. frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js matching dist/: /, /library and /app all 200 and 0 console errors. ponytail: only folding, no unfold-all to match — the click on a row already opens it and the root's own row is one click away; add the pair when the column routinely holds many picked folders and opening them one by one stops being cheap. --- docker/frontend/scripts/library-check.mjs | 47 +++++++++++++++++++++++ docker/frontend/src/Library.tsx | 43 +++++++++++++++++---- docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 3dff333..7ea460e 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -373,6 +373,53 @@ 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 head of the tree is the whole tree's control: a right click on the column's +// own name folds every row that has something under it, so a roll read to the +// bottom of its dates comes back to its own line. The frames in the strip are the +// strip's business and do not move with it. The subfolder opened above carries a +// fold of its own, so it is opened first: the claim is what ONE click does to +// every row at once, not to the one that was already shut. +await page.click('[data-key="lib-node-CheckRoll/2026"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { + timeout: 10_000, + polling: 50, +}); +const deepRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); +const deepTiles = await page.$$eval(thumbs, (els) => els.length); +await page.click('[data-key="lib-root-name"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); +const head = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); +step('a right click on the root name offers to fold the tree', head.join(',') === 'lib-menu-collapse', JSON.stringify(head)); +step( + 'the column name still reads as the folder it belongs to', + (await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A', + await page.getAttribute('[data-key="lib-root-name"]', 'title') +); +await page.click('[data-key="lib-menu-collapse"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { + timeout: 10_000, + polling: 50, +}); +const flatTiles = await page.$$eval(thumbs, (els) => els.length); +const shutRoot = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded'); +// Opening the root back up is the ruler for the rest: only the rows the fold shut +// are still away, and the subfolder two deep is one of them. +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, +}); +const revealed = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); +step( + 'and one click folds every row that has something under it', + deepRows === 4 && shutRoot === 'false' && flatTiles === deepTiles && revealed.join(',') === 'lib-node-CheckRoll,lib-node-CheckRoll/2026,lib-node-CheckRoll/Empty', + `${deepRows} rows → 1, ${revealed.length} back with the root opened, ${flatTiles} tiles kept`, +); +// The open folder is the visit's, not the fold's, so it is put back where the +// sections below left it. +await page.click('[data-key="lib-node-CheckRoll/2026"]'); +await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 }); + // 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 diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 8a7da6a..c814586 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -107,8 +107,9 @@ export function Library() { const [note, setNote] = useState(null); // The folder menu: what a right click opens, and where. No folder means the // right click landed on the empty part of the column, which is the way in for - // one more folder. - const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null); + // one more folder; `root` means it landed on the column's own name — the head + // of the tree, whose one act is folding the whole roll. + const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; x: number; y: number } | null>(null); // Where the drag of the column divider started, and how wide the column was // then — the pointer only ever reports a position, not a distance moved. const grip = useRef<{ x: number; w: number } | null>(null); @@ -513,8 +514,18 @@ export function Library() { }} > {/* The column is titled with the folder the tree belongs to, so - a renamed roll is named where its frames are named. */} - + a renamed roll is named where its frames are named. The name + is the whole tree's handle as well: one right click here + folds every row that has something under it. */} + { + e.preventDefault(); + setMenu({ folder: null, root: true, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) }); + }} + > {root?.label || root?.name} {rows.map((n) => { @@ -703,12 +714,28 @@ export function Library() { )} {/* The context menu: a folder gets what belongs to a folder rather than to a - frame, and the empty part of the column gets the folder that is missing. - A folder whose permission has lapsed gets the one thing that can be done - about it. */} + frame, the empty part of the column gets the folder that is missing, and + the column's own name gets the whole tree folded. A folder whose + permission has lapsed gets the one thing that can be done about it. */} {menu ? (
e.preventDefault()}> - {!menu.folder ? ( + {menu.root ? ( + // Every row that has something under it, the root's own included: a + // long roll comes back to one line per folder, and the click that + // folds a row still opens it again one at a time. + + ) : !menu.folder ? (