Size the preview copy to what the stage actually paints
The copy was fixed at 1600px, so a hidpi screen was already stretching it at 1:1 and every wheel notch made it worse. The stage now reports the size it is painting at — the contain-fit times the pixel ratio times the zoom — and the app re-cuts the source copy to match, quantised and capped at 3200px, which is the largest copy the grade can still afford. The wheel keeps its instant transform; the sharper copy lands once the gesture stops.
This commit is contained in:
@@ -33,9 +33,13 @@ const PREVIEW_MAX = 1600;
|
|||||||
// A committed crop keeps only a fraction of the photo, so the 1600px copy
|
// A committed crop keeps only a fraction of the photo, so the 1600px copy
|
||||||
// leaves too few pixels behind and the result reads as blocky once the stage
|
// leaves too few pixels behind and the result reads as blocky once the stage
|
||||||
// scales it up. While a crop is live the source is re-derived larger, inverted
|
// scales it up. While a crop is live the source is re-derived larger, inverted
|
||||||
// by that fraction, and capped here so the decode stays bounded.
|
// by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is
|
||||||
const CROP_PREVIEW_MAX = 3200;
|
// what the decode can afford: past it a wheel gesture stops keeping up.
|
||||||
|
const PREVIEW_HARD_MAX = 3200;
|
||||||
const CROP_PREVIEW_MIN_FRAC = 0.05;
|
const CROP_PREVIEW_MIN_FRAC = 0.05;
|
||||||
|
// Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it
|
||||||
|
// on every notch.
|
||||||
|
const PREVIEW_QUANTUM = 512;
|
||||||
const PREVIEW_DEBOUNCE_MS = 120;
|
const PREVIEW_DEBOUNCE_MS = 120;
|
||||||
|
|
||||||
const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||||
@@ -169,6 +173,13 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
|
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
|
||||||
// and the whole photo is shown), and applied (the crop alone is rendered).
|
// and the whole photo is shown), and applied (the crop alone is rendered).
|
||||||
|
// What the stage says it is painting at; PREVIEW_MAX until it reports.
|
||||||
|
const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX);
|
||||||
|
const onStageZoom = useCallback((longDevicePx: number) => {
|
||||||
|
setZoomNeedPx(
|
||||||
|
Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
const cropEditing = crop !== 'none' && !cropApplied;
|
const cropEditing = crop !== 'none' && !cropApplied;
|
||||||
const cropLive = crop !== 'none' && cropApplied;
|
const cropLive = crop !== 'none' && cropApplied;
|
||||||
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
|
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
|
||||||
@@ -187,12 +198,15 @@ export function Workspace() {
|
|||||||
const long = Math.max(d?.w ?? 0, d?.h ?? 0);
|
const long = Math.max(d?.w ?? 0, d?.h ?? 0);
|
||||||
return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h);
|
return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h);
|
||||||
})();
|
})();
|
||||||
const previewMax = cropLive
|
const previewMax = Math.min(
|
||||||
? Math.min(
|
PREVIEW_HARD_MAX,
|
||||||
CROP_PREVIEW_MAX,
|
Math.max(
|
||||||
Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
|
zoomNeedPx,
|
||||||
)
|
cropLive
|
||||||
: PREVIEW_MAX;
|
? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
|
||||||
|
: PREVIEW_MAX
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// --- undo -----------------------------------------------------------------
|
// --- undo -----------------------------------------------------------------
|
||||||
// The stack holds whole looks. remember() reads a ref written during render,
|
// The stack holds whole looks. remember() reads a ref written during render,
|
||||||
@@ -375,8 +389,10 @@ export function Workspace() {
|
|||||||
if (prev) URL.revokeObjectURL(prev.url);
|
if (prev) URL.revokeObjectURL(prev.url);
|
||||||
return { bytes, name, url: URL.createObjectURL(blob) };
|
return { bytes, name, url: URL.createObjectURL(blob) };
|
||||||
});
|
});
|
||||||
// The new photo's copy is the 1600px one; a live crop then upgrades it.
|
// The new photo's copy is the 1600px one; a live crop or a zoomed stage
|
||||||
|
// then upgrades it.
|
||||||
previewMaxRef.current = PREVIEW_MAX;
|
previewMaxRef.current = PREVIEW_MAX;
|
||||||
|
setZoomNeedPx(PREVIEW_MAX);
|
||||||
previewJobRef.current++;
|
previewJobRef.current++;
|
||||||
setPreviewBytes(preview);
|
setPreviewBytes(preview);
|
||||||
setGps(found);
|
setGps(found);
|
||||||
@@ -1155,6 +1171,7 @@ export function Workspace() {
|
|||||||
cropRect={cropRect}
|
cropRect={cropRect}
|
||||||
cropWH={cropWH}
|
cropWH={cropWH}
|
||||||
onCropRectChange={setCropRect}
|
onCropRectChange={setCropRect}
|
||||||
|
onStageZoom={onStageZoom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { useI18n } from '../i18n/I18nProvider';
|
import { useI18n } from '../i18n/I18nProvider';
|
||||||
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
|
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
|
||||||
|
|
||||||
// One wheel notch, and how far the photo may be pulled open. Past 1 the render
|
// One wheel notch, and how far the photo may be pulled open.
|
||||||
// is the 1600px copy, so deep zoom is soft by design — it is for reading
|
|
||||||
// framing, not for judging grain.
|
|
||||||
const ZOOM_STEP = 1.15;
|
const ZOOM_STEP = 1.15;
|
||||||
const ZOOM_MAX = 8;
|
const ZOOM_MAX = 8;
|
||||||
|
// How long the wheel has to rest before the stage asks for a sharper copy.
|
||||||
|
const ZOOM_SETTLE_MS = 160;
|
||||||
|
|
||||||
// The centre column: drop target, preview canvas, and the PEEK comparison.
|
// The centre column: drop target, preview canvas, and the PEEK comparison.
|
||||||
// Dragging a photo anywhere on the page works, not just on the frame — the
|
// Dragging a photo anywhere on the page works, not just on the frame — the
|
||||||
@@ -23,6 +23,7 @@ export function ImageStage({
|
|||||||
cropRect,
|
cropRect,
|
||||||
cropWH = null,
|
cropWH = null,
|
||||||
onCropRectChange,
|
onCropRectChange,
|
||||||
|
onStageZoom,
|
||||||
}: {
|
}: {
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
originalUrl: string | null;
|
originalUrl: string | null;
|
||||||
@@ -37,6 +38,10 @@ export function ImageStage({
|
|||||||
// Wanted pixel aspect (w/h) for a fixed ratio, null for FREE.
|
// Wanted pixel aspect (w/h) for a fixed ratio, null for FREE.
|
||||||
cropWH?: number | null;
|
cropWH?: number | null;
|
||||||
onCropRectChange?: (r: CropRect) => void;
|
onCropRectChange?: (r: CropRect) => void;
|
||||||
|
// Longest side, in device pixels, the stage is actually painting the photo
|
||||||
|
// at — the app re-cuts its preview copy to match. Scaled to contain the
|
||||||
|
// photo and multiplied by the zoom and the screen's pixel ratio.
|
||||||
|
onStageZoom?: (longDevicePx: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -201,6 +206,20 @@ export function ImageStage({
|
|||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
}, [clampPan]);
|
}, [clampPan]);
|
||||||
|
|
||||||
|
// Report the size the stage is painting at so the app can re-cut the preview
|
||||||
|
// copy to match — otherwise a retina screen already stretches it at 1:1 and
|
||||||
|
// zooming only makes that worse. Debounced: the wheel keeps its instant
|
||||||
|
// transform and the sharper copy lands once the gesture stops.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onStageZoom) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const b = baseRect();
|
||||||
|
if (!b) return;
|
||||||
|
onStageZoom(Math.round(Math.max(b.w, b.h) * (window.devicePixelRatio || 1) * viewRef.current.s));
|
||||||
|
}, ZOOM_SETTLE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [onStageZoom, baseRect, view.s, previewUrl, cropEditing]);
|
||||||
|
|
||||||
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
|
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
|
||||||
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
|
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user