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 651e0ad09a
commit 2e06e5ab5d
5 changed files with 159 additions and 4 deletions
+15 -1
View File
@@ -18,7 +18,7 @@ import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
import { developRaw, isRawName } from './engine/rawDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, readPhotoFile, saveEdit } from './engine/library';
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
@@ -2051,6 +2051,11 @@ export function Workspace() {
await loadFile(await readPhotoFile(row));
const saved = await loadEdit(id);
if (saved) applyRecipe(saved.recipe);
// The catalogue's own standing for this frame, which the reader set on
// the library's stage: the studio opens the negative the way they last
// saw it, at the same angle. It arrives after `loadFile`, which puts the
// frame on the neutral look and so at 0.
setRotation(row.rot ?? 0);
setLibPhotoId(id);
} catch {
setError(t('err.decode'));
@@ -2070,6 +2075,15 @@ export function Workspace() {
return () => clearTimeout(timer);
}, [libPhotoId, source, recipe]);
// The same filing for the frame's own standing: the catalogue opened this
// frame at an angle, and a turn made here is that number's to keep — the
// library then shows the frame the way it was left, without a second turn.
// The ROTATE chip steps a quarter at a time, so there is nothing to debounce.
useEffect(() => {
if (!libPhotoId || !source) return;
void savePhotoRotation(libPhotoId, rotation);
}, [libPhotoId, source, rotation]);
// The saved frame's own switch. The reel reads `consent`, so the tick answers
// the click at once; only the store's refusal (a failed call) takes it back.
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
+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
+19
View File
@@ -102,6 +102,11 @@ export interface LibraryPhoto {
// it one. It belongs to the catalogue and not to the disk — nothing about the
// file changes — so a rescan carries it over, the way it carries `addedAt`.
star?: number;
// The quarter turn the reader gave the frame so it stands the way they saw
// it, 0 or absent for a frame they have not turned. Like the score it belongs
// to the catalogue rather than to the disk — nothing about the file changes —
// and a rescan carries it over; the studio opens a frame at this angle.
rot?: 0 | 90 | 180 | 270;
addedAt: number;
}
@@ -913,6 +918,7 @@ export async function scanFolder(
size: file.size,
mtime: file.lastModified,
star: seen?.star,
rot: seen?.rot,
addedAt: seen?.addedAt ?? Date.now(),
};
batch.push(row);
@@ -1560,6 +1566,19 @@ export async function setStar(id: string, star: number): Promise<void> {
}
}
// The quarter turn a frame stands at, in the same read-and-put the score uses:
// the catalogue's own, carried over by a rescan, and what the studio opens the
// frame at.
export async function setRotation(id: string, rot: 0 | 90 | 180 | 270): Promise<void> {
const photo = await getPhoto(id);
if (!photo) return;
try {
await ask(PHOTOS, 'readwrite', (s) => s.put({ ...photo, rot }));
} catch {
// Private mode: the turn lives no longer than the visit.
}
}
// The frame itself, at last: nothing is read from the disk until this runs, so
// the catalogue costs thumbnails and no more.
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
+2
View File
@@ -275,6 +275,8 @@ export const en: Dict = {
'lib.ratingAll': 'Any rating',
'lib.ratingAtLeast': '{n}★ and up',
'lib.starHint': 'Give this frame {n} stars',
'lib.rotateLeft': 'Turn this frame left',
'lib.rotateRight': 'Turn this frame right',
'lib.year': 'YEAR',
'lib.yearAll': 'Any year',
'lib.hourFrom': 'Shot from',
+2
View File
@@ -288,6 +288,8 @@ export const vi = {
'lib.ratingAll': 'Mọi mức sao',
'lib.ratingAtLeast': 'Từ {n} sao',
'lib.starHint': 'Cho ảnh này {n} sao',
'lib.rotateLeft': 'Xoay ảnh sang trái',
'lib.rotateRight': 'Xoay ảnh sang phải',
'lib.year': 'NĂM',
'lib.yearAll': 'Mọi năm',
'lib.hourFrom': 'Chụp từ',