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 { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
||||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
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 { offerInstall } from './pwa/install';
|
||||||
import { cameraSupported } from './engine/camera';
|
import { cameraSupported } from './engine/camera';
|
||||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||||
@@ -2051,6 +2051,11 @@ export function Workspace() {
|
|||||||
await loadFile(await readPhotoFile(row));
|
await loadFile(await readPhotoFile(row));
|
||||||
const saved = await loadEdit(id);
|
const saved = await loadEdit(id);
|
||||||
if (saved) applyRecipe(saved.recipe);
|
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);
|
setLibPhotoId(id);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t('err.decode'));
|
setError(t('err.decode'));
|
||||||
@@ -2070,6 +2075,15 @@ export function Workspace() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [libPhotoId, source, recipe]);
|
}, [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 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.
|
// the click at once; only the store's refusal (a failed call) takes it back.
|
||||||
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
|
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
scanBusy,
|
scanBusy,
|
||||||
scanQueue,
|
scanQueue,
|
||||||
scanSession,
|
scanSession,
|
||||||
|
setRotation,
|
||||||
setStar,
|
setStar,
|
||||||
stopScan,
|
stopScan,
|
||||||
stopScanAt,
|
stopScanAt,
|
||||||
@@ -982,6 +983,16 @@ export function Library() {
|
|||||||
await setStar(photo.id, n);
|
await setStar(photo.id, n);
|
||||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star: n } : p)));
|
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);
|
const [shot, setShot] = useState<Shot | null>(null);
|
||||||
// Where the frame was shot, as the coordinates it carries — the fallback for a
|
// Where the frame was shot, as the coordinates it carries — the fallback for a
|
||||||
// file whose place the geocoder could not name.
|
// file whose place the geocoder could not name.
|
||||||
@@ -1037,6 +1048,10 @@ export function Library() {
|
|||||||
}, [frame?.id]);
|
}, [frame?.id]);
|
||||||
|
|
||||||
const [stageUrl, setStageUrl] = useState<string | null>(null);
|
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 stageUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const revokeStageUrl = () => {
|
const revokeStageUrl = () => {
|
||||||
@@ -1050,6 +1065,7 @@ export function Library() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
revokeStageUrl();
|
revokeStageUrl();
|
||||||
setStageUrl(null);
|
setStageUrl(null);
|
||||||
|
setStageFor(null);
|
||||||
if (!frame) return;
|
if (!frame) return;
|
||||||
|
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -1075,6 +1091,7 @@ export function Library() {
|
|||||||
revokeStageUrl();
|
revokeStageUrl();
|
||||||
stageUrlRef.current = url;
|
stageUrlRef.current = url;
|
||||||
setStageUrl(url);
|
setStageUrl(url);
|
||||||
|
setStageFor(frame.id);
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1106,12 @@ export function Library() {
|
|||||||
};
|
};
|
||||||
}, [frame?.id]);
|
}, [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
|
// 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
|
// 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
|
// 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.
|
// hands pictures out only around the eye — keeps the box and says nothing.
|
||||||
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
|
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
|
||||||
urls[photo.id] ? (
|
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}>
|
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
|
||||||
{photo.thumb ? '' : 'RAW'}
|
{photo.thumb ? '' : 'RAW'}
|
||||||
@@ -1548,12 +1576,12 @@ export function Library() {
|
|||||||
</>
|
</>
|
||||||
) : frame ? (
|
) : frame ? (
|
||||||
<>
|
<>
|
||||||
{stageUrl || urls[frame.id] ? (
|
{stageTurned ? (
|
||||||
<img
|
<img
|
||||||
className="adm-stage-img"
|
className="adm-stage-img"
|
||||||
data-key="lib-stage-img"
|
data-key="lib-stage-img"
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
src={stageUrl || urls[frame.id]!}
|
src={stageTurned}
|
||||||
alt=""
|
alt=""
|
||||||
onClick={() => void open(frame)}
|
onClick={() => void open(frame)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -1602,6 +1630,38 @@ export function Library() {
|
|||||||
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
|
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
|
||||||
{t('lib.open')}
|
{t('lib.open')}
|
||||||
</button>
|
</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" />
|
<span className="adm-spacer" />
|
||||||
{/* The frame's own score, and the only place one is
|
{/* The frame's own score, and the only place one is
|
||||||
given: the star that is lit is the score it has,
|
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
|
// 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
|
// 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
|
// 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
|
// 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`.
|
// file changes — so a rescan carries it over, the way it carries `addedAt`.
|
||||||
star?: number;
|
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;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,6 +918,7 @@ export async function scanFolder(
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
mtime: file.lastModified,
|
mtime: file.lastModified,
|
||||||
star: seen?.star,
|
star: seen?.star,
|
||||||
|
rot: seen?.rot,
|
||||||
addedAt: seen?.addedAt ?? Date.now(),
|
addedAt: seen?.addedAt ?? Date.now(),
|
||||||
};
|
};
|
||||||
batch.push(row);
|
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 frame itself, at last: nothing is read from the disk until this runs, so
|
||||||
// the catalogue costs thumbnails and no more.
|
// the catalogue costs thumbnails and no more.
|
||||||
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ export const en: Dict = {
|
|||||||
'lib.ratingAll': 'Any rating',
|
'lib.ratingAll': 'Any rating',
|
||||||
'lib.ratingAtLeast': '{n}★ and up',
|
'lib.ratingAtLeast': '{n}★ and up',
|
||||||
'lib.starHint': 'Give this frame {n} stars',
|
'lib.starHint': 'Give this frame {n} stars',
|
||||||
|
'lib.rotateLeft': 'Turn this frame left',
|
||||||
|
'lib.rotateRight': 'Turn this frame right',
|
||||||
'lib.year': 'YEAR',
|
'lib.year': 'YEAR',
|
||||||
'lib.yearAll': 'Any year',
|
'lib.yearAll': 'Any year',
|
||||||
'lib.hourFrom': 'Shot from',
|
'lib.hourFrom': 'Shot from',
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ export const vi = {
|
|||||||
'lib.ratingAll': 'Mọi mức sao',
|
'lib.ratingAll': 'Mọi mức sao',
|
||||||
'lib.ratingAtLeast': 'Từ {n} sao',
|
'lib.ratingAtLeast': 'Từ {n} sao',
|
||||||
'lib.starHint': 'Cho ảnh này {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.year': 'NĂM',
|
||||||
'lib.yearAll': 'Mọi năm',
|
'lib.yearAll': 'Mọi năm',
|
||||||
'lib.hourFrom': 'Chụp từ',
|
'lib.hourFrom': 'Chụp từ',
|
||||||
|
|||||||
Reference in New Issue
Block a user