diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 4a028db..8b3a2a3 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -132,7 +132,6 @@ export function Workspace() { const [source, setSource] = useState(null); const [previewBytes, setPreviewBytes] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); - const [rendered, setRendered] = useState<{ w: number; h: number; ms: number } | null>(null); const [recipe, setRecipe] = useState(() => ({ ...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 (
@@ -1172,6 +1169,7 @@ export function Workspace() { cropWH={cropWH} onCropRectChange={setCropRect} onStageZoom={onStageZoom} + canCompare={crop === 'none'} />
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 7a8bf84..ce35bc8 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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)', }; diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 794235c..ae3b70e 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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; diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 078f8bd..312bf89 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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; } diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 3bf01fa..bf334a7 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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(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) => { + 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) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + draggingSplit.current = true; + splitAt(e); + }; + + const moveSplit = (e: React.PointerEvent) => { + if (!draggingSplit.current) return; + e.preventDefault(); + splitAt(e); + }; + + const endSplit = (e: React.PointerEvent) => { + draggingSplit.current = false; + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }; + const startPan = (e: React.PointerEvent) => { - 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 ? ( +
+ {/* The original is clipped to everything left of the bar; the + graded render stays the un-clipped base underneath it. */} + + +
+ ) : null} {cropEditing && box && cropRect && onCropRectChange ? (
inputRef.current?.click()}> {t('drop.replace')} + {t('drop.peekHint')}
) : null}