diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 952be8f..26676fb 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -240,6 +240,7 @@ export interface Recipe { baseFilter: BaseFilter; adjustments: ColorAdjustments; frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe' + framePanX?: number; // horizontal pan offset (-0.5 to 0.5) when landscape photo is in portrait frame cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional) useGeotag: boolean; } diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index b5874d2..97f04cc 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -15,7 +15,7 @@ import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; 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, resizedJpeg, scaledJpeg } from './engine/imageOps'; +import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; import { developRaw, isRawName } from './engine/rawDevelop'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; @@ -383,6 +383,7 @@ export function Workspace() { // UNDO and every knob still speak about the look the click left behind. const [preview, setPreview] = useState(null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); + const [framePanX, setFramePanX] = useState(() => session.framePanX ?? 0); const [crop, setCrop] = useState(() => session.crop ?? 'none'); const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); @@ -440,6 +441,7 @@ export function Workspace() { const [gps, setGps] = useState(() => session.gps ?? null); // ISO off the loaded photo's EXIF; null means the strip prints AUTO. const [iso, setIso] = useState(null); + const [shotSpecs, setShotSpecs] = useState(null); // When the loaded photo was taken, off its own EXIF. The GPS stamp prints this // and not the clock, whichever way the position was come by: a photo from the // library can be years old, and the location typed in for it today is still the @@ -936,6 +938,7 @@ export function Workspace() { setGpsManual({ lat: '', lng: '' }); setUseGeotag(false); setIso(null); + setShotSpecs(null); setError(null); setBusy(null); void forgetPhoto(); @@ -1024,6 +1027,7 @@ export function Workspace() { const tagged = exifBytes ?? bytes; const found = await readGps(tagged); const foundIso = await readIso(tagged); + const foundSpecs = await readSpecs(tagged); // The photo's own date, held apart from its GPS: a frame with a date but no // position still stamps the date when the position is typed in or asked of // the device (see the two places the clock is fallen back on, below). @@ -1053,6 +1057,7 @@ export function Workspace() { setPreviewBytes(preview); setGps(found); setIso(foundIso); + setShotSpecs(foundSpecs); const def = defaultLook(); restoreLook(def); setUndo([def]); @@ -3903,6 +3908,14 @@ export function Workspace() { cropRect={cropRect} cropWH={cropWH} dims={stageDims} + frameId={cropEditing ? 'none' : frameId} + framePanX={framePanX} + onFramePanXChange={(pan) => { + setFramePanX(pan); + setRecipe((prev) => ({ ...prev, framePanX: pan })); + }} + shotSpecs={shotSpecs} + place={place} onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={!!source} diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 5f7e698..0b114fb 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -271,6 +271,8 @@ function Catalogue() { // of a tree — which also carries folding the whole tree. const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: string; x: number; y: number } | null>(null); const [photoMenu, setPhotoMenu] = useState<{ photo: LibraryPhoto; x: number; y: number } | null>(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedId, setLastSelectedId] = useState(null); // A reading of this roll that is still waiting its turn. The menu is where // that wait is called off, so it has to know one exists. const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false; @@ -969,6 +971,58 @@ function Catalogue() { return order === 'oldest' ? [...rows].reverse() : rows; }, [shown, minStar, year, from, to, order]); + const handleSelectPhoto = useCallback( + (photo: LibraryPhoto, e: React.MouseEvent) => { + const isCtrl = e.ctrlKey || e.metaKey; + const isShift = e.shiftKey; + + setSelectedIds((prev) => { + const next = new Set(prev); + if (isShift && lastSelectedId) { + const currentIndex = listed.findIndex((p) => p.id === photo.id); + const lastIndex = listed.findIndex((p) => p.id === lastSelectedId); + if (currentIndex !== -1 && lastIndex !== -1) { + const start = Math.min(currentIndex, lastIndex); + const end = Math.max(currentIndex, lastIndex); + for (let i = start; i <= end; i++) { + next.add(listed[i].id); + } + } else { + next.add(photo.id); + } + } else if (isCtrl) { + if (next.has(photo.id)) { + next.delete(photo.id); + } else { + next.add(photo.id); + } + } else { + next.clear(); + next.add(photo.id); + } + return next; + }); + setLastSelectedId(photo.id); + }, + [listed, lastSelectedId] + ); + + const handlePhotoContextMenu = useCallback((photo: LibraryPhoto, e: React.MouseEvent) => { + e.preventDefault(); + setSelectedIds((prev) => { + if (prev.has(photo.id)) { + return prev; + } else { + return new Set([photo.id]); + } + }); + setPhotoMenu({ + photo, + x: Math.min(e.clientX, window.innerWidth - 220), + y: Math.min(e.clientY, window.innerHeight - 240), + }); + }, []); + // The wall is the other list, and it draws its rows the way the strip draws its // tiles: the ones around the eye, with a spacer standing in for the rest. A card // is a card, an `` and an object URL of its own, and twenty thousand of them @@ -1605,17 +1659,11 @@ function Catalogue() { {wall.above > 0 ?