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:
2026-09-17 21:17:54 +07:00
parent 8747b1686b
commit 99be7e96bd
2 changed files with 48 additions and 12 deletions
+22 -3
View File
@@ -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();