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
|
||||
// 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
|
||||
// by that fraction, and capped here so the decode stays bounded.
|
||||
const CROP_PREVIEW_MAX = 3200;
|
||||
// by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is
|
||||
// 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;
|
||||
// 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 FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||
@@ -169,6 +173,13 @@ export function Workspace() {
|
||||
|
||||
// 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).
|
||||
// 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 cropLive = crop !== 'none' && cropApplied;
|
||||
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);
|
||||
return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h);
|
||||
})();
|
||||
const previewMax = cropLive
|
||||
? Math.min(
|
||||
CROP_PREVIEW_MAX,
|
||||
Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
|
||||
)
|
||||
: PREVIEW_MAX;
|
||||
const previewMax = Math.min(
|
||||
PREVIEW_HARD_MAX,
|
||||
Math.max(
|
||||
zoomNeedPx,
|
||||
cropLive
|
||||
? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
|
||||
: PREVIEW_MAX
|
||||
)
|
||||
);
|
||||
|
||||
// --- undo -----------------------------------------------------------------
|
||||
// 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);
|
||||
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;
|
||||
setZoomNeedPx(PREVIEW_MAX);
|
||||
previewJobRef.current++;
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
@@ -1155,6 +1171,7 @@ export function Workspace() {
|
||||
cropRect={cropRect}
|
||||
cropWH={cropWH}
|
||||
onCropRectChange={setCropRect}
|
||||
onStageZoom={onStageZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
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
|
||||
// is the 1600px copy, so deep zoom is soft by design — it is for reading
|
||||
// framing, not for judging grain.
|
||||
// One wheel notch, and how far the photo may be pulled open.
|
||||
const ZOOM_STEP = 1.15;
|
||||
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.
|
||||
// Dragging a photo anywhere on the page works, not just on the frame — the
|
||||
@@ -23,6 +23,7 @@ export function ImageStage({
|
||||
cropRect,
|
||||
cropWH = null,
|
||||
onCropRectChange,
|
||||
onStageZoom,
|
||||
}: {
|
||||
previewUrl: string | null;
|
||||
originalUrl: string | null;
|
||||
@@ -37,6 +38,10 @@ export function ImageStage({
|
||||
// Wanted pixel aspect (w/h) for a fixed ratio, null for FREE.
|
||||
cropWH?: number | null;
|
||||
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 inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -201,6 +206,20 @@ export function ImageStage({
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [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>) => {
|
||||
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user