diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 77e7d39..77060c9 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -860,6 +860,7 @@ export function Workspace() { useEffect(() => { if (!libPhotoId) { setLibFolder([]); + setLibVisible([]); return; } let alive = true; @@ -879,10 +880,12 @@ export function Workspace() { }; }, [libPhotoId]); - // One tile per frame, made the way the shelf makes them and out of the same - // cache: a folder the wall has just drawn is a strip already pointing at the - // tiles it drew. - const libUrls = useThumbUrls(libFolder); + // Only the frames the rail can actually reach are asked for their tile (see + // Filmstrip): the strip arrives with the stage rather than after the folder, + // and a scroll does not wait behind a decode of a frame nobody is looking + // at. The tiles are the shelf's own, out of the cache it draws from. + const [libVisible, setLibVisible] = useState([]); + const libUrls = useThumbUrls(libVisible); // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); @@ -4862,7 +4865,13 @@ export function Workspace() { // nothing for a folder of one, where a strip of one says nothing. filmstrip={ libPhotoId && libFolder.length > 1 ? ( - void openLibraryPhoto(id)} /> + ) : null } onCropRectChange={setCropRect} diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 32793f0..fcaf953 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4237,7 +4237,7 @@ function dropThumbUrl(id: string): void { globalThumbUrls.delete(id); } -function getSyncThumbUrl(photo: LibraryPhoto): string | null { +export function getSyncThumbUrl(photo: LibraryPhoto): string | null { const held = globalThumbUrls.get(photo.id); if (held) return held.url; const blob = photo.thumb ?? getCachedThumb(photo.id); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index de19f89..c796807 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1072,12 +1072,16 @@ button.hint:disabled { cursor: default; } .studio-strip { position: relative; display: flex; + /* The strip keeps its height: the photo above it is the item that gives way + when the stage runs out of room, and a strip squeezed by the column would + clip its own frames — `overflow-x: auto` makes the other axis a scroller + too, so what it cannot show it hides rather than spills. */ + flex: 0 0 auto; gap: 6px; min-width: 0; max-width: 100%; overflow-x: auto; padding-bottom: 4px; - scroll-behavior: smooth; } .strip-frame { flex: 0 0 auto; diff --git a/docker/frontend/src/ui/Filmstrip.tsx b/docker/frontend/src/ui/Filmstrip.tsx index 5434331..3ad262f 100644 --- a/docker/frontend/src/ui/Filmstrip.tsx +++ b/docker/frontend/src/ui/Filmstrip.tsx @@ -1,33 +1,97 @@ -import { useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; +import { getSyncThumbUrl } from '../Library'; import type { LibraryPhoto } from '../engine/library'; +// How much of the roll either side of the rail is asked for a tile, counted in +// screens of the rail. One is the frame the reader is about to walk into. Asking +// for the whole folder instead is a roll of a thousand decoded on the thread +// that is drawing the strip: the strip arrives late, a scroll waits behind a +// decode, the window cannot be resized while they run, and a tile the store has +// not got is a whole photo read off the disk for a frame nobody has looked at. +const AHEAD = 1; + +// One frame of the rail, memoised on its own: a tile landing hands down a new +// map, and without this every frame of the roll is walked again for the one +// that arrived. +const Frame = memo(function Frame({ + id, + name, + url, + active, + onPick, +}: { + id: string; + name: string; + url: string | null; + active: boolean; + onPick: (id: string) => void; +}) { + return ( + + ); +}); + // The frames beside the one on the stage, under the photo: the folder the // catalogue handed over, in the order a file browser reads it. A click is the // same opening the catalogue's own wall makes — the frame arrives with the look // it was last left at — and the frame the stage is holding is the one lit and // the one the rail is scrolled to, wherever the rail had been left. -// -// ponytail: every frame of the folder gets a tile, because the folder is one the -// reader just opened and a folder that is not a roll is not one they scroll. A -// directory holding thousands would want the wall's own windowing (Library.tsx's -// `drawn`); add it when such a folder is a real wait. -export function Filmstrip({ +export const Filmstrip = memo(function Filmstrip({ frames, urls, activeId, onPick, + onVisible, }: { frames: LibraryPhoto[]; urls: Record; activeId: string; onPick: (id: string) => void; + // The frames the rail can reach, handed up so that only those are asked for a + // tile. The roll itself is drawn whole: a strip that grew as it was scrolled + // would not measure the roll the reader is walking along. + onVisible: (photos: LibraryPhoto[]) => void; }) { const rail = useRef(null); + const asked = useRef<{ frames: LibraryPhoto[]; key: string } | null>(null); const at = frames.findIndex((p) => p.id === activeId); + const report = useCallback(() => { + const box = rail.current; + const first = box?.children[0] as HTMLElement | undefined; + const next = box?.children[1] as HTMLElement | undefined; + if (!box || !first) return; + const step = next ? next.offsetLeft - first.offsetLeft : first.clientWidth || 70; + const screen = Math.max(1, Math.round(box.clientWidth / step)); + const inView = Math.max(0, Math.floor(box.scrollLeft / step)); + const inViewTo = Math.min(frames.length, Math.ceil((box.scrollLeft + box.clientWidth) / step)); + const from = Math.max(0, inView - screen * AHEAD); + const to = Math.min(frames.length, inViewTo + screen * AHEAD); + const key = `${from}:${to}`; + if (asked.current && asked.current.key === key && asked.current.frames === frames) return; + asked.current = { frames, key }; + // The frames in view are asked for first: they are the ones being looked at, + // and every tile is work on the thread that is drawing them, so a window + // queued behind its own two screens of margin lands last where it is read. + onVisible([...frames.slice(inView, inViewTo), ...frames.slice(from, inView), ...frames.slice(inViewTo, to)]); + }, [frames, onVisible]); + // The frame in hand centred rather than merely brought into view: a frame at // the rail's edge is half a frame the reader cannot tell from the next one. - // Not `scrollIntoView`, which walks the stage itself off a phone's screen. + // Not `scrollIntoView`, which walks the stage itself off a phone's screen — + // and not `scroll-behavior: smooth` on the rail, which glides across the whole + // roll: a scroll event a frame, and a fresh window asked for at each one, of + // frames the reader never stops on. The rail is put where it is wanted. useEffect(() => { const box = rail.current; const el = box?.children[at] as HTMLElement | undefined; @@ -35,6 +99,31 @@ export function Filmstrip({ box.scrollLeft = el.offsetLeft - box.clientWidth / 2 + el.clientWidth / 2; }, [at, frames]); + // Scrolling and resizing both move what the rail can reach: the phone's rail + // is a different window on the same roll, and the tiles it now needs are not + // the ones the wide one asked for. + useEffect(() => { + const box = rail.current; + report(); + if (!box) return; + let queued = false; + const again = () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + report(); + }); + }; + box.addEventListener('scroll', again, { passive: true }); + const ro = new ResizeObserver(again); + ro.observe(box); + return () => { + box.removeEventListener('scroll', again); + ro.disconnect(); + }; + }, [report]); + return ( // A wheel has no sideways axis, so a plain tick over the roll walks it along.
- {frames.map((photo) => { - const url = urls[photo.id]; - return ( - - ); - })} + {frames.map((photo) => ( + + ))}
); -} +});