From 2e06e5ab5d96720b0563f2465b743816dce811e5 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 6 Oct 2026 17:22:56 +0700 Subject: [PATCH] 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. --- docker/frontend/src/App.tsx | 16 +++- docker/frontend/src/Library.tsx | 124 +++++++++++++++++++++++++- docker/frontend/src/engine/library.ts | 19 ++++ docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + 5 files changed, 159 insertions(+), 4 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 9e238ed..d108296 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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) => { diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 32e3bba..fab3aa4 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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(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(null); const stageUrlRef = useRef(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] ? ( - + ) : (
{photo.thumb ? '' : 'RAW'} @@ -1548,12 +1576,12 @@ export function Library() { ) : frame ? ( <> - {stageUrl || urls[frame.id] ? ( + {stageTurned ? ( void open(frame)} onContextMenu={(e) => { @@ -1602,6 +1630,38 @@ export function Library() { + {/* 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. */} + + {/* 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 ; +} + // 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 diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index a8ad76a..8c6dbc8 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -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 { } } +// 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 { + 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 { diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 9dbb551..2598e84 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index d1fbfd5..b10d292 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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ừ',