diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 5746fbe..9c98b6c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -337,6 +337,18 @@ export function Workspace() { // 4:3 frame keeps full height, and this is what keeps the export from // believing the crop is still 1600px across. const editedLongest = Math.max(1, Math.round(sourceLongest * (cropLive ? cropLongFrac : 1))); + // The same number as a pair, for the row under the photo: the file's shape + // turned by the quarter turn (a 90° copy swaps the sides) and cut by a + // committed crop. A live crop is not the photo yet, so it does not move the + // readout — APPLY does. STRAIGHTEN never changes the size, it fits the + // rotated rectangle inside the same pixels. + const stageDims = (() => { + if (!photoDims || !photoDims.w || !photoDims.h) return null; + const turn = rotation % 180 !== 0; + const w = turn ? photoDims.h : photoDims.w; + const h = turn ? photoDims.w : photoDims.h; + return cropLive ? { w: Math.round(w * cropRect.w), h: Math.round(h * cropRect.h) } : { w, h }; + })(); const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; const previewMax = Math.min( PREVIEW_HARD_MAX, @@ -2559,6 +2571,7 @@ export function Workspace() { cropEditing={cropEditing} cropRect={cropRect} cropWH={cropWH} + dims={stageDims} onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={!!source} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 4f16885..4d22d80 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -459,6 +459,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } box-shadow: var(--shadow); } .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; } /* --- auth dialog -------------------------------------------------------- */ diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index e896ea5..8f3a8e3 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -79,6 +79,7 @@ export function ImageStage({ pickPanelAt = null, marks = [], straightenRuler = null, + dims = null, }: { previewUrl: string | null; originalUrl: string | null; @@ -124,6 +125,10 @@ export function ImageStage({ // FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here // rather than in the panel column because the angle is read off the picture. straightenRuler?: ReactNode; + // The photo's own pixel size, as the stage stands: the file's shape turned by + // 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; }) { const showStraighten = !!straightenRuler; // `marks` is a fresh array on every parent render, and this one is only ever @@ -873,6 +878,13 @@ export function ImageStage({ {previewUrl ? (
+ {/* 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 ? ( + + {dims.w} × {dims.h} + + ) : null} {onClear ? (