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.
This commit is contained in:
2026-10-15 17:52:37 +07:00
parent 164055a6c0
commit b2e2ebb266
4 changed files with 131 additions and 33 deletions
+14 -5
View File
@@ -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<LibraryPhoto[]>([]);
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<Recipe[]>([]);
@@ -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 ? (
<Filmstrip frames={libFolder} urls={libUrls} activeId={libPhotoId} onPick={(id) => void openLibraryPhoto(id)} />
<Filmstrip
frames={libFolder}
urls={libUrls}
activeId={libPhotoId}
onPick={openLibraryPhoto}
onVisible={setLibVisible}
/>
) : null
}
onCropRectChange={setCropRect}
+1 -1
View File
@@ -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);
+5 -1
View File
@@ -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;
+111 -26
View File
@@ -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 (
<button
type="button"
className={'strip-frame' + (active ? ' active' : '')}
data-key={'strip-frame-' + id}
title={name}
aria-label={name}
aria-current={active}
onClick={() => onPick(id)}
>
{url ? <img src={url} alt="" /> : <span className="strip-frame-blank" />}
</button>
);
});
// 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<string, string>;
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<HTMLDivElement>(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.
<div
@@ -45,23 +134,19 @@ export function Filmstrip({
if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY;
}}
>
{frames.map((photo) => {
const url = urls[photo.id];
return (
<button
key={photo.id}
type="button"
className={'strip-frame' + (photo.id === activeId ? ' active' : '')}
data-key={'strip-frame-' + photo.id}
title={photo.name}
aria-label={photo.name}
aria-current={photo.id === activeId}
onClick={() => onPick(photo.id)}
>
{url ? <img src={url} alt="" /> : <span className="strip-frame-blank" />}
</button>
);
})}
{frames.map((photo) => (
<Frame
key={photo.id}
id={photo.id}
name={photo.name}
// Read live, not out of the map handed down: a URL the cache has let
// go of is a broken frame painted at the reader, and this is how the
// strip is told which ones are still its own to draw.
url={urls[photo.id] ? getSyncThumbUrl(photo) : null}
active={photo.id === activeId}
onPick={onPick}
/>
))}
</div>
);
}
});