web: count a roll as it is read, and open a frame without losing the count
Two holes left by the last change. A frame is opened with `window.location.href` rather than a link, so the studio was still handed a fresh page and the scan with it — every route out of the catalogue now goes through `go()`, which pushes the address instead of reloading while a scan is in flight. And a row counted what was filed away rather than what had been read, so it sat at zero for the whole of a first scan: the catalogue's frames only reach IndexedDB in one batch at the end. `ScanProgress.counts` carries the frames the scan has reached, per row, off the scan's own bookkeeping, and the column reads it while the scan runs — the row under the reader's eye moves as the roll is read, and the toolbar's line stays the whole picture. A re-read counts from the start, which is what it is doing. The regression check grows the two: a frame opened mid-scan from the catalogue (of a roll it already holds) has to stay in the page, and the row has to count.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { go } from './nav';
|
||||
import {
|
||||
canBrowseFolders,
|
||||
ensurePermission,
|
||||
@@ -272,7 +273,9 @@ export function Library() {
|
||||
);
|
||||
|
||||
// The frame goes to the studio through the address bar: the studio owns the
|
||||
// load (develop, EXIF, session) and this page owns no part of it.
|
||||
// load (develop, EXIF, session) and this page owns no part of it. While a scan
|
||||
// is running the address bar is the one thing that changes — `go` keeps the
|
||||
// reading where it is instead of reloading the page out from under it.
|
||||
const open = useCallback(async (photo: LibraryPhoto) => {
|
||||
const row = await getPhoto(photo.id);
|
||||
if (!row) return;
|
||||
@@ -284,7 +287,7 @@ export function Library() {
|
||||
setNote(t('lib.missing', { name: row.name }));
|
||||
return;
|
||||
}
|
||||
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
|
||||
go(`/app?lib=${encodeURIComponent(photo.id)}`);
|
||||
}, [t]);
|
||||
|
||||
const rootName = node ? node.split('/')[0] : null;
|
||||
@@ -297,9 +300,14 @@ export function Library() {
|
||||
|
||||
// The tree: one row per picked folder, then one per subfolder, indented by its
|
||||
// depth — the folders the walk has named, whether or not a frame has been read
|
||||
// out of them, each carrying how many frames sit at or under it.
|
||||
// out of them, each carrying how many frames sit at or under it. While a scan is
|
||||
// running the count is the scan's own, so a row says how far the reading has
|
||||
// come; the frames only reach the catalogue in one batch at the end, and a row
|
||||
// that stood still until then would say nothing at all.
|
||||
const read = live?.progress.counts ?? null;
|
||||
const nodes = useMemo<Node[]>(() => {
|
||||
const hits = new Map<string, number>();
|
||||
const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0;
|
||||
const foldersUnder = new Map<string, Set<string>>();
|
||||
// One path per folder between the picked one and `rel`, so a row is drawn for
|
||||
// every folder on the way to a frame as well as for the frame's own.
|
||||
@@ -322,7 +330,7 @@ export function Library() {
|
||||
for (const dir of dirs) add(dir.folder, dir.rel, false);
|
||||
const out: Node[] = [];
|
||||
for (const folder of folders) {
|
||||
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
|
||||
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) });
|
||||
// Path order puts a folder before its own children, which is the order the
|
||||
// indented rows have to come in.
|
||||
for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) {
|
||||
@@ -331,12 +339,12 @@ export function Library() {
|
||||
key: `${folder.name}/${dir}`,
|
||||
label: parts[parts.length - 1],
|
||||
depth: parts.length,
|
||||
count: hits.get(`${folder.name}/${dir}`) ?? 0,
|
||||
count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [dirs, folders, photos]);
|
||||
}, [dirs, folders, photos, read]);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -253,6 +253,13 @@ export interface ScanProgress {
|
||||
total: number;
|
||||
done: number;
|
||||
added: number;
|
||||
// How many frames the scan has read out of each row of the tree, keyed the way
|
||||
// the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for
|
||||
// the rest) and counted the way the rows count: a frame sits on its own row and
|
||||
// on every row above it. A fresh object every frame, so a screen drawing the
|
||||
// column sees a change; the frames themselves only reach the catalogue in one
|
||||
// batch at the end, which would leave every row reading zero until then.
|
||||
counts: Record<string, number>;
|
||||
// Every folder the walk has been into so far, a fresh list each time: the
|
||||
// column draws them while the frames under them are still being read. Not
|
||||
// filed away — a folder name is worth nothing once the scan it came from ends.
|
||||
@@ -284,7 +291,22 @@ export async function scanFolder(
|
||||
// whole scan so a folder that has gone from the disk goes from the column too.
|
||||
const names: string[] = [];
|
||||
const found = new Set<string>();
|
||||
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] };
|
||||
const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} };
|
||||
const counts: Record<string, number> = {};
|
||||
// Count the frame the moment the scan gets to it, before it knows whether the
|
||||
// frame is new: the rows say how far the reading has come, not what it kept.
|
||||
const count = (rel: string) => {
|
||||
const cut = rel.lastIndexOf('/');
|
||||
counts[folder.name] = (counts[folder.name] ?? 0) + 1;
|
||||
let path = '';
|
||||
for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) {
|
||||
if (!part) continue;
|
||||
path = path ? `${path}/${part}` : part;
|
||||
const key = `${folder.name}/${path}`;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
progress.counts = { ...counts };
|
||||
};
|
||||
// The folders this scan has walked into, kept for the whole of it: the column
|
||||
// is redrawn from this list, so it only ever grows.
|
||||
const dirs: LibraryDir[] = [];
|
||||
@@ -333,6 +355,7 @@ export async function scanFolder(
|
||||
break;
|
||||
}
|
||||
progress.done++;
|
||||
count(rel);
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const id = photoId(folder.name, rel);
|
||||
@@ -423,7 +446,7 @@ export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
|
||||
if (live) return Promise.reject(new Error('a scan is already running'));
|
||||
const session: ScanSession = {
|
||||
folder: folder.name,
|
||||
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] },
|
||||
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} },
|
||||
stop: false,
|
||||
jump: null,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ProfilePage } from './ProfilePage';
|
||||
import { PhotosPage } from './PhotosPage';
|
||||
import { Library } from './Library';
|
||||
import { scanSession } from './engine/library';
|
||||
import { go } from './nav';
|
||||
import { InstallPrompt } from './ui/InstallPrompt';
|
||||
import { installTracking } from './track';
|
||||
|
||||
@@ -57,9 +58,7 @@ function Shell() {
|
||||
if (url.origin !== window.location.origin || url.hash) return;
|
||||
if (url.pathname + url.search === here) return;
|
||||
e.preventDefault();
|
||||
history.pushState(null, '', url.pathname + url.search);
|
||||
setRoute(url.pathname + url.search);
|
||||
window.scrollTo(0, 0);
|
||||
go(url.pathname + url.search);
|
||||
};
|
||||
const onPop = () => setRoute(window.location.pathname + window.location.search);
|
||||
document.addEventListener('click', onClick);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { scanSession } from './engine/library';
|
||||
|
||||
// Internal navigation, in one place. The app has no router: a route is a page
|
||||
// load, and that is fine for everything except a route change made while a scan
|
||||
// is reading a roll, because the reading lives in this page's memory and a reload
|
||||
// throws it away. So while a scan is in flight the route changes in place —
|
||||
// push the address, tell whoever draws it — and with no scan running the browser
|
||||
// navigates exactly as it always has.
|
||||
export function go(url: string): void {
|
||||
if (!scanSession()) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
history.pushState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
Reference in New Issue
Block a user