feat(library): a shelf that fills in as it reads, and one that carries the studio's look and frame back with it
- tiles land one at a time. The screenful a picked folder asks for was built all at once, and a HEIC or a RAW in it is a decode of its own, so the strip stood empty until every one of them was done: 2293ms on a roll of eighteen, with the batch arriving in one go (0/18 then 18/18 in a single step). Tiles are built in two lanes now and each one goes up the moment it is drawn — first picture at 36ms, then 1, 2, 4, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18 of 18, done at 2496ms. The threading is handed back between tiles (`scheduler.yield`), so a click still answers while the strip fills: no long tasks either side, worst frame gap 169ms -> 164ms, a view toggle 49ms -> 41ms. The tiles are the same tiles; the wall's is drawn from the same counter. - the studio's look reaches the shelf. A frame graded in the studio writes the tile the shelf draws, at neutral geometry — no turn, crop, watermark or frame, since the shelf turns a tile itself — and every screen holding the tile is told the moment it is written, so the row repaints without a reload. - the shelf comes back on the frame the studio was holding. The frame a visit opened is kept with the visit, and coming back from the studio raises it: the strip centres it and the wall brings its card to the middle, even when the frame sits in another folder of the same tree. A shelf that opened on whatever it was left standing on sent the reader looking for the frame they had just been working on. - the frame's name stands under the picture, in the studio's own meta bar.
This commit is contained in:
@@ -8,6 +8,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps';
|
||||
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
breathe,
|
||||
cachedFolders,
|
||||
cachedPhotos,
|
||||
canBrowseFolders,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
makeThumbs,
|
||||
normPath,
|
||||
onLibraryBlocked,
|
||||
onThumbWritten,
|
||||
pickFolder,
|
||||
readPhotoFile,
|
||||
readPhotos,
|
||||
@@ -96,6 +98,9 @@ const MIN_STAR_KEY = 'recipescam.library.minStar';
|
||||
const YEAR_KEY = 'recipescam.library.year';
|
||||
const FROM_KEY = 'recipescam.library.from';
|
||||
const TO_KEY = 'recipescam.library.to';
|
||||
// The frame the studio is holding (written on its side, see App.tsx): the shelf
|
||||
// comes back up on that frame rather than on wherever the tree was left.
|
||||
const LAST_KEY = 'recipescam.library.last';
|
||||
|
||||
function TimeRangeSelectInput({
|
||||
from,
|
||||
@@ -184,6 +189,11 @@ const STRIP_KEEP = 8;
|
||||
// cards is a couple of hundred pixels tall, so this is the row under the eye and
|
||||
// two beyond it — enough that a flick lands on pictures.
|
||||
const WALL_ROWS = 2;
|
||||
// How many tiles the wall reads and decodes at once when it is the one asking.
|
||||
// The wall's own frames come first and they come visibly: two at a time, the
|
||||
// thread handed back between them (`breathe`), is a shelf that fills in front of
|
||||
// the reader — a screenful of frames read end to end is a dead screen.
|
||||
const WALL_LANES = 2;
|
||||
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
|
||||
// distance past the last row it replaces as the row itself would have.
|
||||
const WALL_GAP = 12;
|
||||
@@ -343,6 +353,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// the first, the way a node that is gone falls back to its folder.
|
||||
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
|
||||
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
|
||||
// This screen is mounted for the life of the tab and only shown and hidden
|
||||
// (see Shell in main.tsx), so a visit that comes back from the studio is not a
|
||||
// mount: the address is what says the shelf is the screen up again.
|
||||
const [here, setHere] = useState(() => window.location.pathname.startsWith('/library'));
|
||||
// How the thumbnail view reads the open folder: every frame it holds, or only
|
||||
// the ones that were given this many stars, shot in this year, shot between
|
||||
// these hours — and in the order the reader picked. A way of looking, not part
|
||||
@@ -1444,7 +1458,20 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
|
||||
[listed, wall.from, wall.to]
|
||||
);
|
||||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
|
||||
// A tile the studio graded while this screen was dark: the copy in hand is the
|
||||
// negative's, and `onThumbWritten` hands over the one that replaces it. The
|
||||
// counter is what makes the rows look again — the list is unchanged, so nothing
|
||||
// else would make them.
|
||||
const [tilesAt, setTilesAt] = useState(0);
|
||||
useEffect(
|
||||
() =>
|
||||
onThumbWritten((id, blob) => {
|
||||
cacheObjectUrl(id, blob);
|
||||
setTilesAt((n) => n + 1);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
|
||||
|
||||
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
||||
const treeRef = useRef<HTMLElement>(null);
|
||||
@@ -1540,7 +1567,39 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [view, listed, wall.ref]);
|
||||
// `up` is in the list for the frame the studio hands back: it is the one that
|
||||
// moves when the shelf comes up on another frame, and a wall standing where it
|
||||
// already was is a wall that would not follow it.
|
||||
}, [view, listed, wall.ref, up]);
|
||||
|
||||
// This screen's own comings and goings: it never unmounts, so `here` is the
|
||||
// only sign of the shelf being the screen up again (see `here` above).
|
||||
useEffect(() => {
|
||||
const onPop = () => setHere(window.location.pathname.startsWith('/library'));
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// Coming back from the studio, the shelf opens on the frame the studio was
|
||||
// holding rather than on wherever the tree was left — that frame is the one the
|
||||
// reader has just been working on. Both views are sent to it: the strip centres
|
||||
// it and the wall, if that is the view up, scrolls to its card. It may also sit
|
||||
// in another folder of the same tree (a frame id and a tree key are spelled the
|
||||
// same way), and a frame the open node cannot list is a frame the shelf cannot
|
||||
// raise at all, so the folder goes with it.
|
||||
const wasHere = useRef(here);
|
||||
useEffect(() => {
|
||||
const came = here && !wasHere.current;
|
||||
wasHere.current = here;
|
||||
if (!came) return;
|
||||
const last = recall(LAST_KEY);
|
||||
if (!last) return;
|
||||
setUp(last);
|
||||
scrolledFrameRef.current = null;
|
||||
centredRef.current = last;
|
||||
const dir = last.slice(0, last.lastIndexOf('/'));
|
||||
if (dir && nodes.some((n) => n.key === dir)) setNode(dir);
|
||||
}, [here, nodes]);
|
||||
|
||||
// The way back, and the way in: the frame the wall is standing on is the card
|
||||
// nearest the middle of its viewport — the same card the reader's eye is on.
|
||||
@@ -2881,7 +2940,7 @@ function cacheObjectUrl(id: string, blob: Blob): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
|
||||
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
|
||||
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
|
||||
|
||||
const [urls, setUrls] = useState<Record<string, string>>(() => {
|
||||
@@ -2920,23 +2979,42 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
|
||||
// The tile is `makeThumb`'s business: the store first, the backup behind it,
|
||||
// and only then the file — a RAW's own preview, or a decode. A frame the disk
|
||||
// will not hand over is a frame with no tile rather than a strip that stops.
|
||||
//
|
||||
// Every tile is drawn the moment it lands, and the file is worked a couple of
|
||||
// frames at a time with the thread handed back between them. Waiting for the
|
||||
// whole shelf and painting once at the end is a screen that shows nothing for
|
||||
// seconds and answers nothing while it waits; this way the frames come in one
|
||||
// after another and a click during the fill still lands.
|
||||
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
|
||||
const updates: Record<string, string> = {};
|
||||
const landed: Record<string, string> = {};
|
||||
for (const photo of list) {
|
||||
if (!alive) return;
|
||||
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
|
||||
const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
|
||||
if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
|
||||
if (kept) landed[photo.id] = cacheObjectUrl(photo.id, kept);
|
||||
}
|
||||
if (alive && Object.keys(updates).length > 0) {
|
||||
setUrls((prev) => ({ ...prev, ...updates }));
|
||||
if (alive && Object.keys(landed).length > 0) {
|
||||
setUrls((prev) => ({ ...prev, ...landed }));
|
||||
}
|
||||
|
||||
const queue = list.filter((p) => !(p.id in landed) && p.handle);
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
|
||||
while (alive && queue.length) {
|
||||
const photo = queue.shift()!;
|
||||
const blob = await makeThumb(photo);
|
||||
if (!alive) return;
|
||||
if (blob) setUrls((prev) => ({ ...prev, [photo.id]: cacheObjectUrl(photo.id, blob) }));
|
||||
await breathe();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [list]);
|
||||
// `stamp` is a tile rewritten under this row: the list is the same one, and
|
||||
// what has to happen again is the read of the store.
|
||||
}, [list, stamp]);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user