diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 77060c9..f09d2ba 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -589,11 +589,19 @@ export function Workspace() { // 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); + // What the stage may be asked for, in device pixels: the photo's own longest + // edge once that is bigger than the cap. `resizedJpeg` never grows a photo, + // so at this size it hands the original bytes straight back — a frame zoomed + // past 3200 is then the frame itself rather than a 3200px copy stretched over + // the screen, which is the blur a reader reads as the thumbnail having been + // opened. The copy still only grows when the stage says it needs it: at + // fitting size the 1600px one stands, and the edit keeps its old cost. + const previewCeiling = Math.max(PREVIEW_HARD_MAX, photoDims ? Math.max(photoDims.w, photoDims.h) : 0); const onStageZoom = useCallback((longDevicePx: number) => { setZoomNeedPx( - Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) + Math.max(PREVIEW_MAX, Math.min(previewCeiling, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) ); - }, []); + }, [previewCeiling]); const cropEditing = crop !== 'none' && !cropApplied; const cropLive = crop !== 'none' && cropApplied; // A committed crop keeps only part of the photo, so the 1600px copy leaves @@ -636,7 +644,7 @@ export function Workspace() { })(); const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; const previewMax = Math.min( - PREVIEW_HARD_MAX, + previewCeiling, Math.max( zoomNeedPx, cropLive