web: fold the whole roll from the head of the tree

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.
This commit is contained in:
2026-09-28 21:03:30 +07:00
parent 291968a342
commit c1ee2cf30a
4 changed files with 86 additions and 8 deletions
+35 -8
View File
@@ -107,8 +107,9 @@ export function Library() {
const [note, setNote] = useState<string | null>(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. */}
<b className="lib-col-title" data-key="lib-root-name" title={root?.label || root?.name}>
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. */}
<b
className="lib-col-title"
data-key="lib-root-name"
title={`${root?.label || root?.name} — ${t('lib.rootHint')}`}
onContextMenu={(e) => {
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}
</b>
{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 ? (
<div className="lib-menu" data-key="lib-menu" style={{ left: menu.x, top: menu.y }} onContextMenu={(e) => 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.
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-collapse"
disabled={parents.size === 0}
onClick={() => {
setMenu(null);
setFolded(new Set(parents));
}}
>
{t('lib.collapseAll')}
</button>
) : !menu.folder ? (
<button
type="button"
className="lib-menu-item"
+2
View File
@@ -236,6 +236,8 @@ export const en: Dict = {
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.nodeHint': 'Right-click to rename, rescan or remove the folder.',
'lib.collapseAll': 'COLLAPSE ALL',
'lib.rootHint': 'Right-click to fold every subfolder.',
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
'lib.viewPreview': 'SINGLE',
+2
View File
@@ -249,6 +249,8 @@ export const vi = {
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.nodeHint': 'Chuột phải để đổi tên, quét lại hay bỏ thư mục.',
'lib.collapseAll': 'THU GỌN TẤT CẢ',
'lib.rootHint': 'Chuột phải để thu gọn mọi thư mục con.',
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
'lib.viewPreview': 'MỘT ẢNH',