From b2e2ebb266d90fb58cf7d99ce62d73ac401f33b5 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 15 Oct 2026 17:52:37 +0700 Subject: [PATCH] fix(studio): the strip asks for the frame in view, and lands where it is put The rail glided to the frame in hand. A glide across a roll of six hundred fires a scroll event a frame, and every one of them asked the thumbnail cache for another window: the tiles of the frames being looked at queued behind two screens of margin, so the strip sat blank exactly where it was being read while it drew frames nobody had reached. The rail now lands where it is put, one window is asked for, and the frames in view are the first asked for a tile. The strip also keeps its height: `overflow-x: auto` makes the other axis a scroller too, so a strip squeezed by the column hid its own frames rather than spilling them. The photo above gives way instead. --- docker/frontend/src/App.tsx | 19 +++- docker/frontend/src/Library.tsx | 2 +- docker/frontend/src/styles/app.css | 6 +- docker/frontend/src/ui/Filmstrip.tsx | 137 ++++++++++++++++++++++----- 4 files changed, 131 insertions(+), 33 deletions(-) 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) => ( + + ))}
); -} +});