Add a draggable crop frame behind APPLY, whole-look undo, and recipe import

This commit is contained in:
2026-09-17 20:14:21 +07:00
parent cc313f2ea9
commit 751beb52c1
6 changed files with 430 additions and 69 deletions
+142 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
// 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
@@ -12,6 +13,10 @@ export function ImageStage({
peek,
onPeek,
onFile,
cropEditing = false,
cropRect,
cropWH = null,
onCropRectChange,
}: {
previewUrl: string | null;
originalUrl: string | null;
@@ -20,10 +25,22 @@ export function ImageStage({
peek: boolean;
onPeek: (on: boolean) => void;
onFile: (file: File) => void;
// Crop editor: shown over the un-cropped preview until APPLY commits it.
cropEditing?: boolean;
cropRect?: CropRect;
// Wanted pixel aspect (w/h) for a fixed ratio, null for FREE.
cropWH?: number | null;
onCropRectChange?: (r: CropRect) => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [over, setOver] = useState(false);
// The crop frame is measured off the img's own box, so it lands on the photo
// whatever the stage does with the space around it.
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
const dragRef = useRef<{ mode: string; rect: CropRect; px: number; py: number } | null>(null);
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
useEffect(() => {
@@ -56,6 +73,86 @@ export function ImageStage({
[onFile, stop]
);
const measure = useCallback(() => {
const img = imgRef.current;
const wrap = wrapRef.current;
if (!img || !wrap) return setBox(null);
const i = img.getBoundingClientRect();
const w = wrap.getBoundingClientRect();
if (!(i.width > 0 && i.height > 0)) return setBox(null);
setBox({ left: i.left - w.left, top: i.top - w.top, width: i.width, height: i.height });
}, []);
useEffect(() => {
if (!cropEditing) {
setBox(null);
return;
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cropEditing, measure, previewUrl]);
const beginDrag = (mode: string) => (e: React.PointerEvent<HTMLElement>) => {
if (!onCropRectChange || !cropRect) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { mode, rect: cropRect, px: e.clientX, py: e.clientY };
};
// Fractions of the PHOTO, not of the screen: the export reads the same
// numbers, so what the frame encloses is what comes out. A fixed ratio keeps
// w/h locked to the wanted pixel aspect restated in fraction space —
// (w*dw)/(h*dh) = ratio, so w/h = ratio / (dw/dh).
const moveDrag = (e: React.PointerEvent<HTMLElement>) => {
const d = dragRef.current;
const img = imgRef.current;
if (!d || !img || !onCropRectChange) return;
const b = img.getBoundingClientRect();
if (!(b.width > 0 && b.height > 0)) return;
e.preventDefault();
const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const r = d.rect;
if (d.mode === 'move') {
onCropRectChange({
x: cl(r.x + (e.clientX - d.px) / b.width, 0, 1 - r.w),
y: cl(r.y + (e.clientY - d.py) / b.height, 0, 1 - r.h),
w: r.w,
h: r.h,
});
return;
}
const fx = cl((e.clientX - b.left) / b.width, 0, 1);
const fy = cl((e.clientY - b.top) / b.height, 0, 1);
// The opposite corner stays put: it is the anchor the frame grows from.
const ax = d.mode.includes('r') ? r.x : r.x + r.w;
const ay = d.mode.includes('b') ? r.y : r.y + r.h;
let w = Math.abs(fx - ax);
let h = Math.abs(fy - ay);
if (cropWH != null) {
const rr = cropWH / (b.width / b.height);
h = Math.max(h, w / rr);
w = h * rr;
if (w > 1) { w = 1; h = w / rr; }
if (h > 1) { h = 1; w = h * rr; }
}
w = cl(w, MIN_CROP_FRAC, 1);
h = cl(h, MIN_CROP_FRAC, 1);
onCropRectChange({
x: cl(d.mode.includes('r') ? ax : ax - w, 0, 1 - w),
y: cl(d.mode.includes('b') ? ay : ay - h, 0, 1 - h),
w,
h,
});
};
const endDrag = (e: React.PointerEvent<HTMLElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className="stage"
@@ -86,8 +183,51 @@ export function ImageStage({
<p className="hint">{t('drop.demoLimit', { px: 2048 })}</p>
</div>
) : (
<div className="canvas-wrap">
<img src={peek && originalUrl ? originalUrl : previewUrl} alt="preview" />
<div className="canvas-wrap" ref={wrapRef}>
{/* draggable={false}: a draggable preview hands the browser the graded
image as a File, and the stage's own drop handler then reads it
back as a new photo — the look re-applied once per drag. */}
<img
ref={imgRef}
src={peek && originalUrl ? originalUrl : previewUrl}
alt="preview"
draggable={false}
onLoad={measure}
/>
{cropEditing && box && cropRect && onCropRectChange ? (
<div
className="crop-layer"
data-key="crop-layer"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
>
<div
className="crop-rect"
data-key="crop-rect"
style={{
left: `${cropRect.x * 100}%`,
top: `${cropRect.y * 100}%`,
width: `${cropRect.w * 100}%`,
height: `${cropRect.h * 100}%`,
}}
onPointerDown={beginDrag('move')}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{['tl', 'tr', 'bl', 'br'].map((h) => (
<span
key={h}
className={`crop-handle ${h}`}
data-key={`crop-handle-${h}`}
onPointerDown={beginDrag(h)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
))}
</div>
</div>
) : null}
{peek ? <div className="busy">{t('drop.peeking')}</div> : null}
</div>
)}