diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 44e89da..f285556 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -16,7 +16,7 @@ import { useI18n } from './i18n/I18nProvider'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; -import { developRaw, isRawName } from './engine/rawDevelop'; +import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDevelop'; import { sharePhoto, type ShareSite } from './engine/share'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; @@ -1182,6 +1182,27 @@ export function Workspace() { void savePhoto(file.name, jpeg); return; } + if (isTiffName(file.name)) { + // A TIFF: no longer a RAW, and nothing in it for the engine to develop + // — but no browser decoder paints one either (see `tiffThumbnail`), so + // it is read by hand at the file's own size and becomes the JPEG the + // studio works on, the same way the HEIC above does. No tier: it is a + // plain reading of pixels the file already holds. + // + // The session keeps the decode, not the file, so a reload opens the + // frame at once instead of paying the read again; the name stays the + // file's own and `isRawName` answers no, so the reload path above shows + // the saved copy as the picture it is. The TIFF goes along as the bytes + // the stamps read — the JPEG carries no EXIF of its own. A file the + // decoders here do not know throws, and the reader is told the frame + // could not be read rather than shown an empty stage. + await clearRaw(); + const jpeg = await tiffDevelop(bytes); + if (!jpeg) throw new Error('no TIFF decoder for this file'); + await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes); + void savePhoto(file.name, jpeg); + return; + } await clearRaw(); await adoptPhoto(bytes, file.name, file); void savePhoto(file.name, bytes); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 9d66e82..dd083a0 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4,7 +4,7 @@ import { go } from './nav'; import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; -import { isRawName, rawThumbnail } from './engine/rawDevelop'; +import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop'; import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { cachedFolders, @@ -1399,7 +1399,15 @@ function Catalogue({ admin }: { admin: boolean }) { // the DOM and the number the browser formats for each date. The frame on the // stage is given a picture wherever it sits — the stage is not the wall. const wall = useWallWindow(listed.length, view === 'grid'); - const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)); + // Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its + // effect on the array it is handed, so a fresh slice every render made that + // effect run every render — a `setUrls` per pass, a render per `setUrls`, and + // thousands of passes a second in which the tiles of the rows scrolled to never + // land at all. + const painted = useMemo( + () => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)), + [listed, wall.from, wall.to] + ); const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame); // Auto-scroll & focus tree sidebar to the active node selected in last session @@ -1427,6 +1435,10 @@ function Catalogue({ admin }: { admin: boolean }) { // Auto-scroll timeline filmstrip to center the active selected thumbnail const scrolledFrameRef = useRef(null); + // The same frame for the wall: the strip hands it over on the way to ALL + // THUMBS, and the wall scrolls to that frame's card rather than opening at the + // top of a shelf the reader was somewhere in the middle of. + const centredRef = useRef(null); useEffect(() => { if (view !== 'preview' || !frame || !listed.length) return; @@ -1449,6 +1461,69 @@ function Catalogue({ admin }: { admin: boolean }) { } }, [view, frame?.id, listed, node, strip.ref]); + // And the wall's half of it: the frame the strip was on, brought to the middle + // of the wall. The card is not in the DOM to begin with — the wall only paints + // the rows around its viewport — so the first passes go to where the row should + // be and the ones after that read the card itself, which is the only honest + // measure: a row stands as tall as its longest caption. The loop is bounded + // rather than driven by state, because the wall reports its window by + // re-rendering and a scroll set inside a render is a scroll the next render + // undoes. + useEffect(() => { + if (view !== 'grid') return; + const id = centredRef.current; + const el = wall.ref.current; + if (!id || !el) return; + const idx = listed.findIndex((p) => p.id === id); + if (idx < 0) { + centredRef.current = null; + return; + } + let alive = true; + let left = 8; + const settle = () => { + if (!alive) return; + const card = el.querySelector(`[data-key="lib-photo-${CSS.escape(id)}"]`); + if (card) { + const box = el.getBoundingClientRect(); + const own = card.getBoundingClientRect(); + el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2); + } else { + // No card drawn: the wall opens on the first screenful, so aim at the row + // and let the next pass find it. The row's step is read off a drawn card + // when there is one, and guessed at when there is not. + const first = el.querySelector(':scope > .adm-card'); + const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length); + const pitch = first ? first.offsetHeight + WALL_GAP : 240; + el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2); + } + if (--left > 0) requestAnimationFrame(settle); + else centredRef.current = null; + }; + requestAnimationFrame(settle); + return () => { + alive = false; + }; + }, [view, listed, wall.ref]); + + // The way back, and the way in: the frame the wall is standing on is the card + // nearest the middle of its viewport — the same card the reader's eye is on. + const openFromWall = () => { + const el = wall.ref.current; + const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0; + let best: { id: string; d: number } | null = null; + for (const card of el ? el.querySelectorAll(':scope > .adm-card') : []) { + const box = card.getBoundingClientRect(); + const d = Math.abs(box.top + box.height / 2 - mid); + if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d }; + } + if (best) setUp(best.id); + // The strip is built afresh when it comes back, so the frame is centred again + // even when it is the frame it was already on. + scrolledFrameRef.current = null; + setView('preview'); + }; + // A frame's score: click a star to give it, click the star it already has to // take the score back. The catalogue holds it, so it is the same score the next // visit reads — and the list on screen is told, rather than read back whole for @@ -1640,6 +1715,12 @@ function Catalogue({ admin }: { admin: boolean }) { // the tile stands in until it lands). const jpeg = await heicToJpeg(new Uint8Array(await file.arrayBuffer()), 2048); if (live) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' })); + } else if (isTiffName(file.name)) { + // A TIFF is nobody's decoder here either (see `tiffThumbnail`), and the + // stage is the frame itself rather than the wall's tile, so it is + // decoded at what a screen shows. + const jpeg = await tiffDevelop(new Uint8Array(await file.arrayBuffer()), 2048); + if (live && jpeg) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' })); } else if (isRawName(file.name)) { const bytes = new Uint8Array(await file.arrayBuffer()); if (!live) return; @@ -1837,7 +1918,7 @@ function Catalogue({ admin }: { admin: boolean }) { aria-pressed={view === 'preview'} aria-label={t('lib.viewPreview')} title={t('lib.viewPreview')} - onClick={() => setView('preview')} + onClick={openFromWall} >