web: read the photo's own size off the row under it

The row below the photo carried CLEAR, OPEN and SAVE ORIGINAL but never said how
big the picture was, so the only way to learn the resolution was to open the
export menu and read the hint there. The size now leads that row, before CLEAR:
the file's own pixels turned by the quarter turn and cut by an applied crop, so
it is the number an export at the photo's own size writes. A live crop does not
move it (nothing is cut yet), STRAIGHTEN never does (the rotated rectangle is
fitted back inside the same pixels), and the guest tier's 2048 cap is still only
announced in the export menu where the file itself is capped.

Verified end to end in photo-dims-probe.cjs: 2400x1800 opens as "2400 × 1800",
a 90 deg turn reads 1800 × 2400, an applied 1:1 crop reads 1800 × 1800, and the
export writes exactly that file.
This commit is contained in:
2026-09-23 07:58:56 +07:00
parent a5d195c5d2
commit 52566f6966
3 changed files with 28 additions and 0 deletions
+13
View File
@@ -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}
+3
View File
@@ -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 -------------------------------------------------------- */
+12
View File
@@ -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 ? (
<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>
) : null}
{onClear ? (
<button type="button" className="btn" data-key="clear-photo" onClick={onClear}>
{t('drop.clear')}