feat(library & studio): add EXIF info bar, portrait frame panning, and multi-photo selection with Panorama/HDR context menu
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Recipe | null>(null);
|
||||
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
|
||||
const [framePanX, setFramePanX] = useState<number>(() => session.framePanX ?? 0);
|
||||
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
|
||||
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
|
||||
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
|
||||
@@ -440,6 +441,7 @@ export function Workspace() {
|
||||
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
|
||||
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
|
||||
const [iso, setIso] = useState<number | null>(null);
|
||||
const [shotSpecs, setShotSpecs] = useState<ShotSpecs | null>(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}
|
||||
|
||||
+107
-19
@@ -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<Set<string>>(new Set());
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(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 `<img>` and an object URL of its own, and twenty thousand of them
|
||||
@@ -1605,17 +1659,11 @@ function Catalogue() {
|
||||
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||||
{painted.map((photo) => (
|
||||
<article
|
||||
className="adm-card"
|
||||
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
key={photo.id}
|
||||
data-key={`lib-photo-${photo.id}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setPhotoMenu({
|
||||
photo,
|
||||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||||
});
|
||||
}}
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo, () => void open(photo))}
|
||||
<div className="adm-meta">
|
||||
@@ -1626,7 +1674,15 @@ function Catalogue() {
|
||||
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key={`lib-open-${photo.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void open(photo);
|
||||
}}
|
||||
>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
</article>
|
||||
@@ -1775,18 +1831,14 @@ function Catalogue() {
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}`}
|
||||
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
data-key={`lib-thumb-${photo.id}`}
|
||||
title={photo.name}
|
||||
onClick={() => setUp(photo.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setPhotoMenu({
|
||||
photo,
|
||||
x: Math.min(e.clientX, window.innerWidth - 220),
|
||||
y: Math.min(e.clientY, window.innerHeight - 160),
|
||||
});
|
||||
onClick={(e) => {
|
||||
setUp(photo.id);
|
||||
handleSelectPhoto(photo, e);
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo)}
|
||||
<span className="adm-thumb-cap">{photo.name}</span>
|
||||
@@ -1966,6 +2018,42 @@ function Catalogue() {
|
||||
style={{ left: photoMenu.x, top: photoMenu.y }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{selectedIds.size >= 2 ? (
|
||||
<div style={{ padding: '4px 10px 6px', fontSize: '11px', fontWeight: 600, color: 'var(--accent)', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
{t('lib.count', { n: selectedIds.size })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-photo-merge-panorama"
|
||||
onClick={() => {
|
||||
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
|
||||
setPhotoMenu(null);
|
||||
setNote(`${t('lib.mergePanorama')} (${count} photos)`);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">🏞️</span>
|
||||
{t('lib.mergePanorama')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-photo-merge-hdr"
|
||||
onClick={() => {
|
||||
const count = selectedIds.size >= 2 ? selectedIds.size : 1;
|
||||
setPhotoMenu(null);
|
||||
setNote(`${t('lib.mergeHdr')} (${count} photos)`);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">🌄</span>
|
||||
{t('lib.mergeHdr')}
|
||||
</button>
|
||||
|
||||
<div style={{ height: '1px', background: 'var(--border-soft)', margin: '4px 0' }} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
|
||||
@@ -173,6 +173,7 @@ export async function readCapturedAt(bytes: Uint8Array): Promise<number | null>
|
||||
// screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows
|
||||
// and nothing about a file it does not know is invented here.
|
||||
export interface ShotSpecs {
|
||||
camera: string | null;
|
||||
iso: number | null;
|
||||
// A focal length in millimetres, an f-number, and an exposure in seconds.
|
||||
focal: number | null;
|
||||
@@ -187,14 +188,23 @@ export interface ShotSpecs {
|
||||
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
|
||||
try {
|
||||
const tags = (await exifr.parse(bytes, {
|
||||
pick: ['ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
|
||||
pick: ['Make', 'Model', 'ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
|
||||
})) as Record<string, unknown> | undefined;
|
||||
if (!tags) return null;
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(Array.isArray(v) ? v[0] : v);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const make = tags.Make ? String(tags.Make).trim() : '';
|
||||
const model = tags.Model ? String(tags.Model).trim() : '';
|
||||
let camera: string | null = null;
|
||||
if (model) {
|
||||
camera = make && !model.toLowerCase().startsWith(make.toLowerCase()) ? `${make} ${model}` : model;
|
||||
} else if (make) {
|
||||
camera = make;
|
||||
}
|
||||
return {
|
||||
camera,
|
||||
iso: num(tags.ISO ?? tags.ISOSpeedRatings),
|
||||
focal: num(tags.FocalLength),
|
||||
aperture: num(tags.FNumber),
|
||||
@@ -216,16 +226,18 @@ const round = (n: number) => String(Math.round(n * 10) / 10);
|
||||
// fraction a camera prints (`1/320s`), and everything the file does not have is
|
||||
// left out rather than stood in for — an empty line is what a file with no EXIF
|
||||
// gets, and the caller prints nothing.
|
||||
export function specsLine(specs: ShotSpecs | null): string {
|
||||
if (!specs) return '';
|
||||
export function specsLine(specs: ShotSpecs | null, place?: string | null, dims?: { w: number; h: number } | null): string {
|
||||
if (!specs && !place && !dims) return '';
|
||||
const parts = [
|
||||
specs.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||||
specs.focal ? `${round(specs.focal)}mm` : '',
|
||||
specs.aperture ? `f/${round(specs.aperture)}` : '',
|
||||
specs.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||||
specs.width && specs.height ? `${specs.width}×${specs.height}` : '',
|
||||
specs?.camera ? specs.camera : '',
|
||||
specs?.aperture ? `f/${round(specs.aperture)}` : '',
|
||||
specs?.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||||
specs?.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||||
specs?.focal ? `${round(specs.focal)}mm` : '',
|
||||
dims ? `${dims.w} × ${dims.h}` : (specs?.width && specs?.height ? `${specs.width} × ${specs.height}` : ''),
|
||||
place ? place : '',
|
||||
];
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface StudioState {
|
||||
recipe?: Recipe;
|
||||
simId?: string | null;
|
||||
frameId?: FrameId;
|
||||
framePanX?: number;
|
||||
crop?: CropRatio;
|
||||
cropRect?: CropRect;
|
||||
// false = the frame is on screen but not accepted yet (still draggable).
|
||||
|
||||
@@ -36,13 +36,13 @@ export const en: Dict = {
|
||||
'drop.clear': 'Clear',
|
||||
'drop.peeking': 'SHOWING THE ORIGINAL',
|
||||
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
|
||||
'drop.compare': 'Compare with original picture',
|
||||
'drop.keep': 'SAVE ORIGINAL',
|
||||
'drop.compare': 'Compare',
|
||||
'drop.keep': 'Save original',
|
||||
'drop.keepHint': 'Save the untouched photo to the device photo library. A browser that cannot share files downloads it instead.',
|
||||
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
|
||||
|
||||
// The live viewfinder (see vi.ts for what the status line is for).
|
||||
'cam.open': 'OPEN CAMERA',
|
||||
'cam.open': 'Open camera',
|
||||
'cam.shot': 'SHOOT',
|
||||
'cam.flip': 'Switch front/back camera',
|
||||
'cam.starting': 'Opening the camera…',
|
||||
@@ -52,7 +52,7 @@ export const en: Dict = {
|
||||
'cam.fail': 'The camera did not open.',
|
||||
'cam.shotFail': 'The shot failed — try again.',
|
||||
|
||||
'hist.title': 'HISTOGRAM',
|
||||
'hist.title': 'Histogram',
|
||||
'hist.drag': 'Drag to move',
|
||||
'hist.hide': 'Hide the histogram',
|
||||
|
||||
@@ -262,6 +262,8 @@ export const en: Dict = {
|
||||
'lib.openExplorer': 'Open in window explorer',
|
||||
'lib.openLocation': 'Open at location',
|
||||
'lib.openStudio': 'Open in studio',
|
||||
'lib.mergePanorama': 'Panorama Merge…',
|
||||
'lib.mergeHdr': 'HDR Merge (Anti-ghosting)…',
|
||||
'lib.copiedPath': 'Copied location to clipboard: {path}',
|
||||
'lib.edited': 'edited',
|
||||
'lib.size': '{mb} MB',
|
||||
|
||||
@@ -45,15 +45,15 @@ export const vi = {
|
||||
'drop.clear': 'Xoá ảnh',
|
||||
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
|
||||
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
|
||||
'drop.compare': 'So với ảnh gốc',
|
||||
'drop.keep': 'LƯU ẢNH GỐC',
|
||||
'drop.compare': 'So sánh',
|
||||
'drop.keep': 'Lưu ảnh gốc',
|
||||
'drop.keepHint': 'Lưu nguyên ảnh gốc chưa chỉnh vào thư viện ảnh của máy. Trình duyệt không chia sẻ được tệp thì ảnh sẽ được tải về.',
|
||||
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
|
||||
|
||||
// The live viewfinder. The status line is the one fact the user cannot see
|
||||
// for themselves: the preview runs at its own resolution, and how fast the
|
||||
// device grades it is what tells them the phone is keeping up.
|
||||
'cam.open': 'MỞ CAMERA',
|
||||
'cam.open': 'Mở camera',
|
||||
'cam.shot': 'CHỤP',
|
||||
'cam.flip': 'Đổi camera trước/sau',
|
||||
'cam.starting': 'Đang mở camera…',
|
||||
@@ -64,7 +64,7 @@ export const vi = {
|
||||
'cam.shotFail': 'Chụp không thành công — thử lại.',
|
||||
|
||||
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
|
||||
'hist.title': 'HISTOGRAM',
|
||||
'hist.title': 'Histogram',
|
||||
'hist.drag': 'Kéo để di chuyển',
|
||||
'hist.hide': 'Ẩn histogram',
|
||||
|
||||
@@ -274,7 +274,9 @@ export const vi = {
|
||||
'lib.open': 'Mở trong studio',
|
||||
'lib.openExplorer': 'Open in window explorer',
|
||||
'lib.openLocation': 'Open at location',
|
||||
'lib.openStudio': 'Open in studio',
|
||||
'lib.openStudio': 'Mở trong studio',
|
||||
'lib.mergePanorama': 'Ghép Panorama…',
|
||||
'lib.mergeHdr': 'Ghép HDR (Chống nhòe)…',
|
||||
'lib.copiedPath': 'Đã sao chép đường dẫn: {path}',
|
||||
'lib.edited': 'đã chỉnh',
|
||||
'lib.size': '{mb} MB',
|
||||
|
||||
@@ -819,9 +819,22 @@ button.hint:disabled { cursor: default; }
|
||||
color: var(--text-dim);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stage .photo-meta-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
/* The photo's own size, first in that row: monospaced so the digits do not
|
||||
shuffle the buttons sideways while a crop is being dragged. */
|
||||
.stage .toolbar .dims { font-family: var(--mono); white-space: nowrap; }
|
||||
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
|
||||
|
||||
@@ -1033,6 +1046,7 @@ button.hint:disabled { cursor: default; }
|
||||
}
|
||||
.adm-thumb:hover { border-color: var(--accent-line); }
|
||||
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.adm-thumb.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); background: var(--accent-soft); }
|
||||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
|
||||
/* The three destinations sit on one line under the photo, each an independent
|
||||
@@ -1087,6 +1101,10 @@ button.hint:disabled { cursor: default; }
|
||||
background: var(--bg-elev);
|
||||
padding: 10px;
|
||||
}
|
||||
.adm-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||
/* A frame whose RAW carries no preview of its own: the tile stands in for the
|
||||
picture and opens the file just the same. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
|
||||
import { bitmapReader, samplePixel } from '../engine/imageOps';
|
||||
import { MIN_CROP_FRAC, type CropRect, type FrameId, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
|
||||
import { bitmapReader, samplePixel, specsLine, type ShotSpecs } from '../engine/imageOps';
|
||||
import { RAW_ACCEPT } from '../engine/rawDevelop';
|
||||
// A phone's HEIC is offered beside the camera RAWs: it is the same offer to a
|
||||
// file the browser will not open by itself (see engine/heicDevelop.ts).
|
||||
@@ -286,6 +286,11 @@ export function ImageStage({
|
||||
onMaskCreate,
|
||||
onMaskEdit,
|
||||
dims = null,
|
||||
shotSpecs = null,
|
||||
place = null,
|
||||
frameId = 'none',
|
||||
framePanX = 0,
|
||||
onFramePanXChange,
|
||||
}: {
|
||||
previewUrl: string | null;
|
||||
originalUrl: string | null;
|
||||
@@ -377,6 +382,11 @@ export function ImageStage({
|
||||
// the quarter turn and cut by a committed crop, so the number in the row under
|
||||
// the photo is the resolution an export at the photo's own size renders.
|
||||
dims?: { w: number; h: number } | null;
|
||||
shotSpecs?: ShotSpecs | null;
|
||||
place?: string | null;
|
||||
frameId?: FrameId;
|
||||
framePanX?: number;
|
||||
onFramePanXChange?: (panX: number) => void;
|
||||
}) {
|
||||
const showStraighten = !!straightenRuler;
|
||||
// The curve graph is a fresh node on every parent render too, and the measure
|
||||
@@ -1987,14 +1997,13 @@ export function ImageStage({
|
||||
)}
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="toolbar">
|
||||
{/* The row's own facts, left of its buttons: how big the photo under
|
||||
the stage is. The number is the render's, so a crop moves it. */}
|
||||
{dims ? (
|
||||
<span className="hint dims" data-key="photo-dims">
|
||||
{dims.w} × {dims.h}
|
||||
</span>
|
||||
<>
|
||||
{shotSpecs || place || dims ? (
|
||||
<div className="photo-meta-bar" data-key="photo-meta-bar">
|
||||
{specsLine(shotSpecs ?? null, place, dims)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="toolbar">
|
||||
{onClear ? (
|
||||
<button type="button" className="btn" data-key="clear-photo" onClick={onClear}>
|
||||
{t('drop.clear')}
|
||||
@@ -2044,6 +2053,7 @@ export function ImageStage({
|
||||
</button>
|
||||
<span className="hint">{t('drop.peekHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{busy ? <div className="busy">{busy}</div> : null}
|
||||
|
||||
Reference in New Issue
Block a user