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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user