Offer a compare-with-original split, and drop the render-time readout
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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