web: walk a roll layer by layer, and let the strip drop the branch

This commit is contained in:
2026-09-28 19:55:02 +07:00
parent bd206a8be9
commit d0fddba1e8
7 changed files with 282 additions and 63 deletions
+37 -5
View File
@@ -75,6 +75,10 @@ export function Library() {
// 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());
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
// own — and shallow is what a folder of folders is for.
const [deep, setDeep] = useState(true);
const [up, setUp] = useState<string | null>(null);
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [colWidth, setColWidth] = useState(() => {
@@ -89,6 +93,10 @@ export function Library() {
// one more folder.
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null);
const stopRef = useRef(false);
// The folder the reader last clicked, as the walk spells it ('' for the picked
// folder, `2026/04/` under it): the scan in flight pulls it to the front of
// its queue, so the branch in front of the eye is read first.
const jumpRef = useRef<string | 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);
@@ -166,7 +174,7 @@ export function Library() {
setBusy(true);
setNote(null);
try {
const result = await scanFolder(folder, setProgress, () => stopRef.current);
const result = await scanFolder(folder, setProgress, () => stopRef.current, () => jumpRef.current);
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
await reload();
} catch {
@@ -251,6 +259,12 @@ export function Library() {
const rootName = node ? node.split('/')[0] : null;
// A row's folder as the walk spells it: '' for the picked folder itself, the
// path under it for a subfolder, and nothing for a row belonging to another
// folder — that one is not a folder of this roll.
const relOf = (key: string): string | null =>
!rootName ? null : key === rootName ? '' : key.startsWith(`${rootName}/`) ? `${key.slice(rootName.length + 1)}/` : null;
// The tree: one row per picked folder, then one per subfolder a frame sits in,
// indented by its depth, each carrying how many frames it holds at or under it.
const nodes = useMemo<Node[]>(() => {
@@ -316,13 +330,15 @@ export function Library() {
setNode(nodes.find((n) => n.key === node.split('/')[0])?.key ?? nodes[0]?.key ?? null);
}, [loaded, node, nodes]);
// What the open node draws: its own frames, and the ones in every subfolder
// under it — a roll's top level usually holds no file of its own.
// What the open node draws: its own frames, and — unless the branch has been
// shut out of the strip — the ones in every subfolder under it, because a
// roll's top level usually holds no file of its own.
const shown = useMemo(() => {
if (!rootName) return photos;
const dir = node === rootName ? '' : (node ?? '').slice(rootName.length + 1);
return photos.filter((p) => p.folder === rootName && (dir === '' || p.dir === dir || p.dir.startsWith(`${dir}/`)));
}, [node, photos, rootName]);
const below = dir === '' ? (p: LibraryPhoto) => p.dir !== '' : (p: LibraryPhoto) => p.dir.startsWith(`${dir}/`);
return photos.filter((p) => p.folder === rootName && (p.dir === dir || (deep && below(p))));
}, [deep, node, photos, rootName]);
const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null;
const urls = useThumbUrls(shown);
@@ -415,6 +431,19 @@ export function Library() {
<rect x="9" y="9" width="5" height="5" rx="1" />
</svg>
</button>
{/* Whether the strip draws the branch under the open folder or
only the folder itself. The label says what the click does,
`on` says where the strip is now. */}
<button
type="button"
className={`chip${deep ? ' on' : ''}`}
data-key="lib-subs"
aria-pressed={deep}
title={t('lib.subsHint')}
onClick={() => setDeep((prev) => !prev)}
>
{t(deep ? 'lib.subsHide' : 'lib.subsShow')}
</button>
</>
) : null}
</div>
@@ -465,6 +494,9 @@ export function Library() {
// the rows go.
onClick={() => {
setNode(n.key);
// The folder just clicked is the one a scan in flight
// reads next, whatever layer it sits in.
jumpRef.current = relOf(n.key);
if (!under) return;
setFolded((prev) => {
const next = new Set(prev);