Offer a compare-with-original split, and drop the render-time readout

This commit is contained in:
2026-09-17 21:39:07 +07:00
parent 99be7e96bd
commit 3af5a315e2
5 changed files with 134 additions and 11 deletions
+96 -3
View File
@@ -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}