Add a draggable crop frame behind APPLY, whole-look undo, and recipe import
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user