Offer a compare-with-original split, and drop the render-time readout
This commit is contained in:
@@ -24,6 +24,7 @@ export function ImageStage({
|
||||
cropWH = null,
|
||||
onCropRectChange,
|
||||
onStageZoom,
|
||||
canCompare = false,
|
||||
}: {
|
||||
previewUrl: string | null;
|
||||
originalUrl: string | null;
|
||||
@@ -42,6 +43,9 @@ export function ImageStage({
|
||||
// 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;
|
||||
// Compare needs the un-cropped photo on one side and the render on the other,
|
||||
// so it is only offered while the crop is untouched.
|
||||
canCompare?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -58,6 +62,11 @@ export function ImageStage({
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
const panRef = useRef<{ px: number; py: number } | null>(null);
|
||||
// Compare: the original on the left of the split, the render on the right.
|
||||
// 0.5 is the middle of the photo, which is where the bar starts.
|
||||
const [compare, setCompare] = useState(false);
|
||||
const [split, setSplit] = useState(0.5);
|
||||
const draggingSplit = useRef(false);
|
||||
|
||||
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
|
||||
useEffect(() => {
|
||||
@@ -101,14 +110,23 @@ export function ImageStage({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropEditing) {
|
||||
if (!cropEditing && !compare) {
|
||||
setBox(null);
|
||||
return;
|
||||
}
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [cropEditing, measure, previewUrl]);
|
||||
}, [cropEditing, compare, measure, previewUrl]);
|
||||
|
||||
// Compare is a property of the un-cropped photo, so a crop taking over (or a
|
||||
// new photo arriving) puts it away.
|
||||
useEffect(() => {
|
||||
if (!canCompare) setCompare(false);
|
||||
}, [canCompare]);
|
||||
useEffect(() => {
|
||||
setCompare(false);
|
||||
}, [originalUrl]);
|
||||
|
||||
// The img's box at scale 1 — the natural size fitted into the wrap, never
|
||||
// enlarged. Computed rather than measured so the transform cannot feed back
|
||||
@@ -220,8 +238,36 @@ export function ImageStage({
|
||||
return () => clearTimeout(timer);
|
||||
}, [onStageZoom, baseRect, view.s, previewUrl, cropEditing]);
|
||||
|
||||
// The bar is dragged in the photo's own coordinates, so it tracks the image
|
||||
// through any zoom the stage has applied.
|
||||
const splitAt = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
if (!(r.width > 0)) return;
|
||||
setSplit(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)));
|
||||
};
|
||||
|
||||
const startSplit = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
draggingSplit.current = true;
|
||||
splitAt(e);
|
||||
};
|
||||
|
||||
const moveSplit = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingSplit.current) return;
|
||||
e.preventDefault();
|
||||
splitAt(e);
|
||||
};
|
||||
|
||||
const endSplit = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggingSplit.current = false;
|
||||
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
|
||||
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
|
||||
if (cropEditing || compare || viewRef.current.s <= 1 || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
@@ -356,6 +402,40 @@ export function ImageStage({
|
||||
}
|
||||
}
|
||||
/>
|
||||
{compare && canCompare && originalUrl && box ? (
|
||||
<div
|
||||
className="compare-layer"
|
||||
data-key="compare-layer"
|
||||
style={{
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
transform:
|
||||
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
|
||||
}}
|
||||
onPointerDown={startSplit}
|
||||
onPointerMove={moveSplit}
|
||||
onPointerUp={endSplit}
|
||||
onPointerCancel={endSplit}
|
||||
>
|
||||
{/* The original is clipped to everything left of the bar; the
|
||||
graded render stays the un-clipped base underneath it. */}
|
||||
<img
|
||||
className="compare-orig"
|
||||
data-key="compare-orig"
|
||||
src={originalUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{ clipPath: `inset(0 ${(1 - split) * 100}% 0 0)` }}
|
||||
/>
|
||||
<span
|
||||
className="compare-bar"
|
||||
data-key="compare-bar"
|
||||
style={{ left: `${split * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{cropEditing && box && cropRect && onCropRectChange ? (
|
||||
<div
|
||||
className="crop-layer"
|
||||
@@ -399,6 +479,19 @@ export function ImageStage({
|
||||
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
|
||||
{t('drop.replace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={compare ? 'btn primary' : 'btn'}
|
||||
data-key="compare-toggle"
|
||||
aria-pressed={compare}
|
||||
disabled={!canCompare}
|
||||
onClick={() => {
|
||||
setSplit(0.5);
|
||||
setCompare((v) => !v);
|
||||
}}
|
||||
>
|
||||
{t('drop.compare')}
|
||||
</button>
|
||||
<span className="hint">{t('drop.peekHint')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user