web: a row's menu is the tree's own and speaks only of that row, and only the row being read admits to it

The menu that hangs off a folder is a menu read mid-scan, with the eye still
moving down the column, and it was drawn to the measure of a page rather than of
a list: a box wide enough to be a destination, type the size of a heading. A
catalogue's own context menu is a thing passed over on the way somewhere else —
it is sized to its words and to the row it hangs off, and the reader's eye steps
across it without stopping. The type comes down a point, the rows tighten to the
tree's own measure, and the frame around them thickens to a radius the rows
themselves use. The clamp that keeps it in the window follows the smaller box,
so a menu opened near the right or the bottom edge no longer floats an inch off
the pointer for room it does not use.

A row that is being read has exactly one thing to be asked of it — that it stop
— where a row at rest has the reading that brings it up to date. The menu said
RESCAN either way, greyed while a scan was up, which is a command offered and
refused in the same breath. The two never both apply, so the menu now carries
the one the row is actually in: a roll being read offers STOP SCAN and stops
that roll; a roll at rest offers UPDATE... and reads it again. A row with
folders under it grows a third thing — a fold that closes the branch and not
merely the row, because a reader who is done with a tree means the whole of it
shut, and closing only the row they happened to point at leaves the children
standing open underneath it.

The last is a matter of what a row is allowed to claim. Every row of a roll
spanning the tree spun while that roll was being read, so a reader looking at a
tree of a hundred folders saw a hundred rows each insisting it was the one being
read, when the reading had been pointed at one of them. A row now says it is
being read only when it is the row the reading was kept to — the folder it was
pointed at, which for a whole roll is the roll's own row. A tree that says all
of itself is busy when one branch of it is says nothing a reader can trust.
This commit is contained in:
2026-10-01 19:00:21 +07:00
parent 4f87f022da
commit d5da6ebcba
4 changed files with 86 additions and 33 deletions
+66 -20
View File
@@ -195,7 +195,7 @@ export function Library() {
// right click landed on the empty part of the column, which is the way in for
// one more folder; `root` means it landed on a row at the top level — the head
// of a tree — which also carries folding the whole tree.
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; x: number; y: number } | null>(null);
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: string; 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);
@@ -1060,14 +1060,17 @@ export function Library() {
onContextMenu={(e) => {
if (e.target !== e.currentTarget) return;
e.preventDefault();
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) });
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 150), y: Math.min(e.clientY, window.innerHeight - 150) });
}}
>
{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 && live?.folder === folder.name;
// The row being read says so, and no other: a roll is read
// from one folder down, and every row of that roll spinning
// is a tree that says all of itself is being read when one
// branch of it is. The row is the folder the reading was
// kept to, which for a whole roll is the roll's own row.
const scanning = !!folder && live?.folder === folder.name && relOf(n.key) === live.progress.from;
const under = parents.has(n.key);
return (
<button
@@ -1120,8 +1123,9 @@ export function Library() {
folder,
root: n.depth === 0,
rel: under ? `${under}/` : '',
x: Math.min(e.clientX || box.left, window.innerWidth - 190),
y: Math.min(e.clientY || box.top, window.innerHeight - 96),
node: n.key,
x: Math.min(e.clientX || box.left, window.innerWidth - 150),
y: Math.min(e.clientY || box.top, window.innerHeight - 150),
});
}}
>
@@ -1431,19 +1435,61 @@ export function Library() {
<span className="lib-menu-icon" aria-hidden="true">✎</span>
{t('lib.rename')}
</button>
<button
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={!!live}
onClick={() => {
setMenu(null);
void scan(menu.folder!, false, menu.rel ?? '');
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{t('lib.rescan')}
</button>
{/* A roll being read has one thing to be asked of it — that it
stop — where a roll at rest has the reading that brings it up
to date; the two never both apply, so the menu carries the one
the row is actually in. */}
{live?.folder === menu.folder.name ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-menu-stop-${menu.folder.name}`}
onClick={() => {
setMenu(null);
stopScan();
}}
>
<span className="lib-menu-icon" aria-hidden="true">■</span>
{t('lib.stop')}
</button>
) : (
<button
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={!!live}
onClick={() => {
setMenu(null);
void scan(menu.folder!, false, menu.rel ?? '');
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{t('lib.update')}
</button>
)}
{/* A row with folders under it is a row that folds, and the branch
it holds goes with it: a reader who is done with a tree means
the whole of it shut, not the one row they happened to point
at with its children left open underneath. */}
{menu.node && parents.has(menu.node) ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-fold-${menu.node}`}
onClick={() => {
const key = menu.node!;
setMenu(null);
setExpanded((prev) => {
const next = new Set(prev);
for (const k of next) if (k === key || k.startsWith(`${key}/`)) next.delete(k);
return next;
});
}}
>
<span className="lib-menu-icon" aria-hidden="true">▴</span>
{t('lib.collapse')}
</button>
) : null}
<button
type="button"
className="lib-menu-item"
+3 -2
View File
@@ -241,11 +241,12 @@ export const en: Dict = {
'lib.stop': 'STOP SCAN',
'lib.rename': 'RENAME FOLDER',
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
'lib.rescan': 'RESCAN',
'lib.update': 'UPDATE...',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.nodeHint': 'Right-click to rename, rescan or remove the folder.',
'lib.nodeHint': 'Right-click to rename, update, collapse or remove the folder.',
'lib.collapseAll': 'COLLAPSE ALL',
'lib.collapse': 'COLLAPSE',
'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',
+3 -2
View File
@@ -253,11 +253,12 @@ export const vi = {
'lib.stop': 'DỪNG QUÉT',
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
'lib.rescan': 'QUÉT LẠI',
'lib.update': 'CẬP NHẬT...',
'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.nodeHint': 'Chuột phải để đổi tên, cập nhật, thu gọn hay bỏ thư mục.',
'lib.collapseAll': 'THU GỌN TẤT CẢ',
'lib.collapse': 'THU GỌN',
'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',
+14 -9
View File
@@ -1210,26 +1210,31 @@ button.hint:disabled { cursor: default; }
when the window is narrow. */
.lib-icon { padding: 5px 8px; justify-content: center; }
/* A folder's own menu, on a right click. Fixed, so the tree's own scroll cannot
clip it; the position is clamped to the window when it is drawn. */
clip it; the position is clamped to the window when it is drawn.
Sized after a catalogue's own menu rather than after a page's: it hangs off a
row of a list the reader is scanning with their eyes, and a box of commands as
loud as the list is a box that stops the scanning. The type and the rows are
the tree's own, a shade tighter. */
.lib-menu {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
min-width: 170px;
padding: 4px;
min-width: 148px;
padding: 3px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.lib-menu-item {
border: 0;
border-radius: var(--radius-sm);
background: none;
padding: 7px 10px;
font-size: 12px;
letter-spacing: 0.04em;
padding: 4px 7px;
font-size: 11px;
line-height: 1.35;
letter-spacing: 0.03em;
text-align: left;
cursor: pointer;
/* Each command leads with a mark of its own — rename, scan, forget, fold — the
@@ -1237,9 +1242,9 @@ button.hint:disabled { cursor: default; }
column of marks rather than a block of uppercase. */
display: flex;
align-items: center;
gap: 8px;
gap: 7px;
}
.lib-menu-icon { flex: 0 0 auto; width: 12px; text-align: center; opacity: 0.85; }
.lib-menu-icon { flex: 0 0 auto; width: 10px; text-align: center; opacity: 0.85; }
.lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
.lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }