library: stand a frame up in the catalogue, wall, strip and studio

The library stage carried no way to correct a frame shot on its side, and
nothing kept the correction. A quarter turn now rides on the catalogue row
(`rot`, beside `star`, carried over by a rescan), the two chips under the
preview step it, and the tile, the strip thumbnail and the stage all read it
through one bake so a frame is never stood on its side in one place and
upright in another. The studio opens a frame at that angle and a turn made
there is filed straight back onto the row, so either end of the turn is
what the frame comes up at next time.
This commit is contained in:
2026-10-06 17:22:56 +07:00
parent 162024b9b3
commit e2e0297f31
5 changed files with 159 additions and 4 deletions
+121 -3
View File
@@ -27,6 +27,7 @@ import {
scanBusy,
scanQueue,
scanSession,
setRotation,
setStar,
stopScan,
stopScanAt,
@@ -982,6 +983,16 @@ export function Library() {
await setStar(photo.id, n);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star: n } : p)));
}, []);
// The frame's own standing, filed the moment it is turned: the row is what the
// next visit, the next scan and the studio all read the angle off, so the turn
// is written down rather than held for the move to the next frame — a tab
// closed on a turned frame still knows the angle it was left at.
const turn = useCallback(async (photo: LibraryPhoto, dir: 1 | -1) => {
const next = ((((photo.rot ?? 0) + dir * 90) % 360) + 360) % 360 as 0 | 90 | 180 | 270;
await setRotation(photo.id, next);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, rot: next } : p)));
}, []);
const [shot, setShot] = useState<Shot | null>(null);
// Where the frame was shot, as the coordinates it carries — the fallback for a
// file whose place the geocoder could not name.
@@ -1037,6 +1048,10 @@ export function Library() {
}, [frame?.id]);
const [stageUrl, setStageUrl] = useState<string | null>(null);
// The frame that URL was read for. The raise moves on a render before the URL
// is dropped, so without this the frame that has just come up is handed the
// negative of the one before it — a URL the effect below is about to revoke.
const [stageFor, setStageFor] = useState<string | null>(null);
const stageUrlRef = useRef<string | null>(null);
const revokeStageUrl = () => {
@@ -1050,6 +1065,7 @@ export function Library() {
useEffect(() => {
revokeStageUrl();
setStageUrl(null);
setStageFor(null);
if (!frame) return;
let live = true;
@@ -1075,6 +1091,7 @@ export function Library() {
revokeStageUrl();
stageUrlRef.current = url;
setStageUrl(url);
setStageFor(frame.id);
} else if (url) {
URL.revokeObjectURL(url);
}
@@ -1089,6 +1106,12 @@ export function Library() {
};
}, [frame?.id]);
// What the stage hands the frame on as: the file's own pixels, turned by the
// angle the row carries. The raised frame's own tile stands in until its file
// has been read, and a URL read for another frame is not the frame's at all.
const stageSrc = stageUrl && stageFor === frame?.id ? stageUrl : frame ? urls[frame.id] ?? null : null;
const stageTurned = useTurned(stageSrc, frame?.rot ?? 0);
// A wheel tick over the frame is the zoom — a negative is the one thing on this
// screen worth filling the window with — and the point under the pointer is the
// point that stays put, which is how a reader aims at what to magnify. The
@@ -1126,7 +1149,12 @@ export function Library() {
// hands pictures out only around the eye — keeps the box and says nothing.
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
urls[photo.id] ? (
<img src={urls[photo.id]} alt="" loading="lazy" onClick={onPick} title={onPick ? t('lib.open') : undefined} />
<TileImg
src={urls[photo.id]!}
rot={photo.rot ?? 0}
onPick={onPick}
title={onPick ? t('lib.open') : undefined}
/>
) : (
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
{photo.thumb ? '' : 'RAW'}
@@ -1548,12 +1576,12 @@ export function Library() {
</>
) : frame ? (
<>
{stageUrl || urls[frame.id] ? (
{stageTurned ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
ref={frameRef}
src={stageUrl || urls[frame.id]!}
src={stageTurned}
alt=""
onClick={() => void open(frame)}
onContextMenu={(e) => {
@@ -1602,6 +1630,38 @@ export function Library() {
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
{t('lib.open')}
</button>
{/* The frame's own standing: a negative shot on its side
is turned here, once, and the catalogue keeps the
angle — the next visit, the next scan and the studio
all read it off the row, so it is never turned
twice. Two buttons and not a dial: a quarter is the
only turn a photo is ever out by. */}
<button
type="button"
className="chip lib-icon"
data-key="lib-turn-left"
aria-label={t('lib.rotateLeft')}
title={t('lib.rotateLeft')}
onClick={() => void turn(frame, -1)}
>
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.4">
<path d="M3.2 6.2A5.2 5.2 0 1 1 3.5 10" />
<path d="M1.6 3.1v3.4h3.4" />
</svg>
</button>
<button
type="button"
className="chip lib-icon"
data-key="lib-turn-right"
aria-label={t('lib.rotateRight')}
title={t('lib.rotateRight')}
onClick={() => void turn(frame, 1)}
>
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.4">
<path d="M12.8 6.2A5.2 5.2 0 1 0 12.5 10" />
<path d="M14.4 3.1v3.4h-3.4" />
</svg>
</button>
<span className="adm-spacer" />
{/* The frame's own score, and the only place one is
given: the star that is lit is the score it has,
@@ -1908,6 +1968,64 @@ export function Library() {
);
}
// A frame's own pixels at the angle its row carries. The turn is baked into the
// bitmap rather than put on the element, because everything drawn over the photo
// on this stage measures that element: the wheel's zoom takes its aim from the
// element's own box and the frame is fitted to it, and both are read off a box
// the browser has already swapped the sides of once the element is turned. A
// quarter turn is a pixel swap — nothing is interpolated — so the canvas only
// pays the re-encode. The unturned frame stands while the turn is being made,
// which keeps a click from blanking the stage; and a frame at 0 hands the source
// straight on, so an unturned roll decodes nothing.
function useTurned(src: string | null, deg: number): string | null {
const [made, setMade] = useState<{ url: string; for: string } | null>(null);
useEffect(() => {
const key = `${src}|${deg}`;
if (!src || !deg) return;
let alive = true;
let url: string | null = null;
void (async () => {
try {
const bmp = await createImageBitmap(await (await fetch(src)).blob());
const swap = deg % 180 !== 0;
const canvas = new OffscreenCanvas(swap ? bmp.height : bmp.width, swap ? bmp.width : bmp.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
bmp.close();
return;
}
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((deg * Math.PI) / 180);
ctx.drawImage(bmp, -bmp.width / 2, -bmp.height / 2);
bmp.close();
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 });
if (!alive) return;
url = URL.createObjectURL(blob);
setMade({ url, for: key });
} catch {
// Undecodable here: the frame stands as the file wrote it.
}
})();
return () => {
alive = false;
if (url) URL.revokeObjectURL(url);
// The turn this run made goes with it. The element would otherwise be
// handed a revoked URL the moment the angle moves back to one this same
// source was already turned to.
setMade((prev) => (prev && prev.url === url ? null : prev));
};
}, [src, deg]);
return made && made.for === `${src}|${deg}` ? made.url : src;
}
// A tile, at the angle its row carries: the wall and the strip read the same
// turn the stage does, so a frame the reader has stood upright is not lying on
// its side two rows down. One canvas per turned frame and none at all for the
// rest — a tile at 0 hands its URL straight on.
function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick?: () => void; title?: string }) {
return <img src={useTurned(src, rot) ?? src} alt="" loading="lazy" onClick={onPick} title={title} />;
}
// One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and the list the panels paint is
// the only list to make them for. `also` is a frame drawn outside that list — the