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:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ừ',
|
||||
|
||||
Reference in New Issue
Block a user