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
+2 -4
View File
@@ -132,7 +132,6 @@ export function Workspace() {
const [source, setSource] = useState<LoadedSource | null>(null);
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [rendered, setRendered] = useState<{ w: number; h: number; ms: number } | null>(null);
const [recipe, setRecipe] = useState<Recipe>(() => ({
...DEFAULT_RECIPES[0],
@@ -316,7 +315,6 @@ export function Workspace() {
const token = ++renderToken.current;
const timer = setTimeout(async () => {
setBusy(t('act.exporting'));
const started = performance.now();
try {
const out = await renderPhoto({
source: previewBytes,
@@ -342,7 +340,6 @@ export function Workspace() {
if (prev) URL.revokeObjectURL(prev);
return url;
});
setRendered({ w: out.width, h: out.height, ms: Math.round(performance.now() - started) });
} catch (err) {
if (token === renderToken.current) setError(t('err.generic', { msg: String(err) }));
} finally {
@@ -1045,7 +1042,7 @@ export function Workspace() {
})),
];
const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready');
const status = busy;
return (
<div className="shell">
@@ -1172,6 +1169,7 @@ export function Workspace() {
cropWH={cropWH}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={crop === 'none'}
/>
</div>
+1 -2
View File
@@ -24,6 +24,7 @@ export const en: Dict = {
'drop.replace': 'Choose another photo',
'drop.peeking': 'SHOWING THE ORIGINAL',
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
'drop.compare': 'Compare with original picture',
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
'tab.presets': 'PRESETS',
@@ -112,8 +113,6 @@ export const en: Dict = {
'err.generic': 'Something went wrong: {msg}',
'err.offline': 'Could not reach the server.',
'meta.ready': 'Ready',
'meta.rendered': 'Rendered in {ms}ms · {w}×{h}',
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
};
+1 -2
View File
@@ -27,6 +27,7 @@ export const vi = {
'drop.replace': 'Đổi ảnh khác',
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
'drop.compare': 'So với ảnh gốc',
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
'tab.presets': 'PRESETS',
@@ -115,8 +116,6 @@ export const vi = {
'err.generic': 'Có lỗi xảy ra: {msg}',
'err.offline': 'Không gọi được máy chủ.',
'meta.ready': 'Sẵn sàng',
'meta.rendered': 'Kết xuất {ms}ms · {w}×{h}',
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
} as const;
+34
View File
@@ -173,6 +173,40 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
/* Crop editor: the layer is the photo's own box, the rect is the framing and
its shadow is what shades the part being cut away. */
/* Compare: the original left of the split, the grade right of it. The layer is
the photo's own box and carries the stage's transform, so the bar stays put
on the photo whatever the zoom or pan. */
.compare-layer {
position: absolute;
overflow: hidden;
border-radius: var(--radius-sm);
cursor: ew-resize;
touch-action: none;
}
.compare-orig { position: absolute; inset: 0; width: 100%; height: 100%; box-shadow: none; }
.compare-bar {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.compare-bar::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 30px;
height: 30px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}
.crop-layer { position: absolute; overflow: hidden; border-radius: var(--radius-sm); pointer-events: none; }
.crop-rect { position: absolute; border: 1px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); cursor: move; pointer-events: auto; touch-action: none; }
.crop-handle { position: absolute; width: 16px; height: 16px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
+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}