From 751beb52c10208ee0d2a7c1488481927e1925749 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 20:14:21 +0700 Subject: [PATCH] Add a draggable crop frame behind APPLY, whole-look undo, and recipe import --- docker/frontend/src/App.tsx | 335 +++++++++++++++++++++----- docker/frontend/src/engine/session.ts | 7 +- docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + docker/frontend/src/styles/app.css | 9 + docker/frontend/src/ui/ImageStage.tsx | 144 ++++++++++- 6 files changed, 430 insertions(+), 69 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 0a7d46c..85f0269 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -14,9 +14,17 @@ import { api, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; -import { exportRecipeXml } from '../shared/utils/recipeShare'; +import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare'; import { writeJpegExif } from '../shared/utils/exifWrite'; -import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types'; +import { + CROP_W_H, + DEFAULT_CROP_RECT, + type CropRatio, + type CropRect, + type FrameId, + type GPSInfo, + type Recipe, +} from '../shared/types'; import type { MsgKey } from './i18n/vi'; // Preview renders on a downscaled copy; export always re-renders the original @@ -32,10 +40,9 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'wallframe', label: 'frame.wall' }, ]; -// ponytail: nothing here is a rectangle the user drags yet, so only the fixed -// ratios are listed — 'free' (DEFAULT_CROP_RECT + drag on the canvas) lands -// with the crop overlay in W3. The type already carries it. -const CROPS: Exclude[] = ['none', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; +// FREE draws the frame on the canvas and lets the user drag its corners, so +// every ratio is offered here — no ratio is applied until APPLY. +const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; const TEMP_PRESETS: { label: string; kelvin: number }[] = [ { label: 'AUTO', kelvin: 5500 }, { label: 'DAYLIGHT', kelvin: 5600 }, @@ -83,6 +90,23 @@ interface LoadedSource { url: string; } +// Everything a single UNDO step puts back. The recipe alone was not enough: +// frame, crop, rotation, marks and geotag all change the photo too. +interface Look { + recipe: Recipe; + simId: string | null; + frameId: FrameId; + crop: CropRatio; + cropRect: CropRect; + cropApplied: boolean; + rotation: 0 | 90 | 180 | 270; + markOn: boolean; + markText: string; + markStyle: { color: string; size: number }; + useGeotag: boolean; + gpsStyle: { color: string; showName: boolean; showTime: boolean }; +} + export function Workspace() { const { t } = useI18n(); @@ -108,7 +132,10 @@ export function Workspace() { })); const [simId, setSimId] = useState(() => session.simId ?? null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); - const [crop, setCrop] = useState>(() => session.crop ?? 'none'); + const [crop, setCrop] = useState(() => session.crop ?? 'none'); + const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); + const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); + const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); const [openParam, setOpenParam] = useState(null); const [openGroup, setOpenGroup] = useState(null); @@ -131,10 +158,60 @@ export function Workspace() { // the session. Small JSON, so no debounce is worth the risk of losing the last // edit; the photo has its own store (engine/session.ts). useEffect(() => { - saveState({ recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); - }, [recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); + saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); + }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); - const [undo, setUndo] = useState([]); + // Crop has three states: none, framed-but-uncommitted (the frame is draggable + // and the whole photo is shown), and applied (the crop alone is rendered). + const cropEditing = crop !== 'none' && !cropApplied; + const cropLive = crop !== 'none' && cropApplied; + const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; + + // --- undo ----------------------------------------------------------------- + // The stack holds whole looks. remember() reads a ref written during render, + // so it is stable and always snapshots the state as of the last painted + // frame — the same value the user is looking at when they press the button. + const [undo, setUndo] = useState([]); + const lookRef = useRef(null); + lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle }; + const remember = useCallback(() => { + const snap = lookRef.current; + if (snap) setUndo((stack) => [...stack.slice(-19), snap]); + }, []); + + const restoreLook = useCallback((l: Look) => { + setRecipe(l.recipe); + setSimId(l.simId); + setFrameId(l.frameId); + setCrop(l.crop); + setCropRect(l.cropRect); + setCropApplied(l.cropApplied); + setRotation(l.rotation); + setMarkOn(l.markOn); + setMarkText(l.markText); + setMarkStyle(l.markStyle); + setUseGeotag(l.useGeotag); + setGpsStyle(l.gpsStyle); + }, []); + + const defaultLook = useCallback((): Look => { + const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); + const cur = lookRef.current?.recipe ?? DEFAULT_RECIPES[0]; + return { + recipe: { ...(provia ? filmSimToRecipe(provia) : cur), id: cur.id, name: cur.name, isCustom: true }, + simId: provia ? provia.id : null, + frameId: 'none', + crop: 'none', + cropRect: DEFAULT_CROP_RECT, + cropApplied: false, + rotation: 0, + markOn: false, + markText: '', + markStyle: { color: '#ffffff', size: 1 }, + useGeotag: false, + gpsStyle: { color: '#f59e0b', showName: true, showTime: true }, + }; + }, []); const [exporting, setExporting] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); @@ -199,14 +276,16 @@ export function Workspace() { const out = await renderPhoto({ source: previewBytes, recipe, - frameId, + // An uncommitted crop is framed on the bare photo, so the overlay + // measures exactly what the engine will cut. + frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo: gps, assets: engine.assets, options: { sharpen: true, photoRotation: rotation, - cropWH: crop === 'none' ? undefined : CROP_W_H[crop], + cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, @@ -227,7 +306,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); + }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); // --- input --------------------------------------------------------------- // One photo, two ways in: a drop/pick (which also stores it for the next @@ -237,17 +316,30 @@ export function Workspace() { async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); const found = await readGps(bytes); + // The photo's own shape drives the default crop frame. Read off the + // downscaled copy — same aspect, a fraction of the decode cost. + const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' })) + .then((bmp) => { + const d = { w: bmp.width, h: bmp.height }; + bmp.close(); + return d; + }) + .catch(() => null); + setPhotoDims(dims); setSource((prev) => { if (prev) URL.revokeObjectURL(prev.url); return { bytes, name, url: URL.createObjectURL(blob) }; }); setPreviewBytes(preview); setGps(found); + // A new photo starts a new history whose floor is the default look, so + // UNDO walks all the way back and still leaves the photo loaded. + setUndo([defaultLook()]); if (keepGeo) return; setUseGeotag(!!found); if (!found) setGpsManual({ lat: '', lng: '' }); }, - [] + [defaultLook] ); const loadFile = useCallback( @@ -277,8 +369,6 @@ export function Workspace() { }, [loadFile]); // --- recipe edits --------------------------------------------------------- - const remember = useCallback(() => setUndo((stack) => [...stack.slice(-19), recipe]), [recipe]); - const applyRecipe = useCallback( (next: Recipe) => { remember(); @@ -295,38 +385,73 @@ export function Workspace() { [] ); + // A slider drag is one edit, not one per pixel: the first move after the + // ruler opened takes the snapshot, the rest ride on it. + const sliderEditRef = useRef(false); + useEffect(() => { + sliderEditRef.current = false; + }, [openParam]); + const setAdjustmentOnce = useCallback( + (patch: Partial) => { + if (!sliderEditRef.current) { + sliderEditRef.current = true; + remember(); + } + setAdjustment(patch); + }, + [remember, setAdjustment] + ); + + // --- crop ----------------------------------------------------------------- + // Picking a ratio only opens the frame; the crop is committed by APPLY. + const pickCrop = useCallback( + (ratio: CropRatio) => { + remember(); + setCrop(ratio); + setCropApplied(false); + if (ratio === 'none' || ratio === 'free' || !photoDims) return; + // Largest centred rect of that shape the photo can hold. CROP_W_H is a + // PIXEL aspect; in fractions (of the photo's width/height) it becomes + // R = ratio / (w/h), so a 3:4 frame lands on a 3:4 photo and a square one + // on anything else. + const k = rotation % 180 === 0 ? photoDims.w / photoDims.h : photoDims.h / photoDims.w; + const R = CROP_W_H[ratio] / k; + const w = R >= 1 ? 1 : R; + const h = R >= 1 ? 1 / R : 1; + setCropRect({ x: (1 - w) / 2, y: (1 - h) / 2, w, h }); + }, + [remember, photoDims, rotation] + ); + + const applyCrop = useCallback(() => setCropApplied(true), []); + + // CANCEL throws the framing away — the pick pushed the look it replaced, so + // one undo is exactly "never mind". + const cancelCrop = useCallback(() => { + setUndo((stack) => { + const last = stack[stack.length - 1]; + if (last) restoreLook(last); + return stack.slice(0, -1); + }); + }, [restoreLook]); + const reset = useCallback(() => { remember(); // Back to the startup look, exactly like the phone: the PROVIA sim, every // knob neutral, no frame/crop, no rotation, both marks off and restyled. - const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); - setRecipe((prev) => ({ - ...(provia ? filmSimToRecipe(provia) : prev), - id: prev.id, - name: prev.name, - isCustom: true, - })); - setSimId(provia ? provia.id : null); - setFrameId('none'); - setCrop('none'); - setRotation(0); - setMarkOn(false); - setMarkText(''); - setMarkStyle({ color: '#ffffff', size: 1 }); - setUseGeotag(false); - setGpsStyle({ color: '#f59e0b', showName: true, showTime: true }); + restoreLook(defaultLook()); setOpenParam(null); setOpenGroup(null); setShowRecipes(false); - }, [remember]); + }, [remember, restoreLook, defaultLook]); const undoLast = useCallback(() => { setUndo((stack) => { const last = stack[stack.length - 1]; - if (last) setRecipe(last); + if (last) restoreLook(last); return stack.slice(0, -1); }); - }, []); + }, [restoreLook]); const saveRecipe = useCallback(async () => { if (!user) { @@ -387,6 +512,40 @@ export function Workspace() { [user] ); + // IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved + // from another account. `shared/utils/recipeShare` is the app's own parser, so + // the look arrives intact — a guest gets it for the session, like CREATE. + const importRecipeFile = useCallback( + async (file: File) => { + setError(null); + try { + const parsed = importRecipeXml(await file.text()); + const entry: Recipe = { ...parsed, id: `import-${Date.now()}`, isCustom: true }; + setCreated((list) => [entry, ...list]); + applyRecipe(entry); + setFrameId(parsed.frameId ?? 'none'); + setUseGeotag(!!parsed.useGeotag); + setShowRecipes(true); + } catch (err) { + setError(t('err.generic', { msg: String(err) })); + } + }, + [applyRecipe, t] + ); + + // The picker is built on the spot: a second in the page + // would compete with the stage's own for `input[type=file]`. + const pickRecipeFile = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.recipe,application/xml,text/xml'; + input.onchange = () => { + const file = input.files?.[0]; + if (file) void importRecipeFile(file); + }; + input.click(); + }, [importRecipeFile]); + const exportPhoto = useCallback(async () => { if (!engine || !source) return; setExporting(true); @@ -397,7 +556,8 @@ export function Workspace() { const out = await renderPhoto({ source: src, recipe, - frameId, + // The file is the preview: an uncommitted frame is not exported. + frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo: gps, assets: engine.assets, @@ -405,7 +565,7 @@ export function Workspace() { dpi: 300, sharpen: true, photoRotation: rotation, - cropWH: crop === 'none' ? undefined : CROP_W_H[crop], + cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : tier.watermark @@ -437,7 +597,7 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, useGeotag, gps, gpsStyle, rotation, crop, markOn, markText, markStyle, place, user, t]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t]); // --- strips --------------------------------------------------------------- // One row at a time, exactly the phone's rule: opening a ruler closes the @@ -500,7 +660,7 @@ export function Workspace() { step: def.step, display: def.display(def.get(recipe.adjustments)), edited: def.get(recipe.adjustments) !== def.defaultValue, - onChange: (value) => setAdjustment(def.set(value)), + onChange: (value) => setAdjustmentOnce(def.set(value)), // Back reopens the strip the row was launched from (COLOR TEMP -> TEMP). onBack: () => { const g = PARAM_GROUP[def.key]; @@ -509,7 +669,7 @@ export function Workspace() { }, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openParam, recipe.adjustments, setAdjustment]); + }, [openParam, recipe.adjustments, setAdjustmentOnce]); // Option strips: a chip opens one, each option picks a value. `off` is the // neutral value the chip glows amber against. @@ -523,24 +683,33 @@ export function Workspace() { cx: { label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none', options: CHROME_OPTIONS, - onPick: (v) => setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] }), + onPick: (v) => { + remember(); + setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] }); + }, }, cxb: { label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none', options: CHROME_OPTIONS, - onPick: (v) => setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] }), + onPick: (v) => { + remember(); + setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] }); + }, }, dr: { label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'), options: DR_OPTIONS, - onPick: (v) => setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] }), + onPick: (v) => { + remember(); + setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] }); + }, }, - // ponytail: fixed ratios only — 'free' needs the drag-on-canvas rect (W3); - // the type already carries it. + // Picking a ratio frames it on the photo (drag the corners, then APPLY) — + // nothing is cut until APPLY. crop: { label: 'CROP', off: 'none', value: crop, - options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c })), - onPick: (v) => setCrop(v as Exclude), + options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })), + onPick: (v) => pickCrop(v as CropRatio), }, // ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only. rotate: { @@ -552,7 +721,10 @@ export function Workspace() { { v: '180', d: '180°' }, { v: '270', d: '270°' }, ], - onPick: (v) => setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)), + onPick: (v) => { + remember(); + setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)); + }, }, style: { label: 'PHOTO STYLE', off: 'sim-provia', @@ -573,32 +745,50 @@ export function Workspace() { { v: 'color-temp', d: 'COLOR TEMP' }, ...TEMP_PRESETS.map((p) => ({ v: String(p.kelvin), d: p.label })), ], - onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) })), + onPick: (v) => { + if (v !== 'color-temp') remember(); + return v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) }); + }, }, wmColor: { label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color, options: WM_COLORS, - onPick: (v) => setMarkStyle((prev) => ({ ...prev, color: v })), + onPick: (v) => { + remember(); + setMarkStyle((prev) => ({ ...prev, color: v })); + }, }, wmSize: { label: 'TEXT SIZE', off: '1', value: String(markStyle.size), options: WM_SIZES, - onPick: (v) => setMarkStyle((prev) => ({ ...prev, size: Number(v) })), + onPick: (v) => { + remember(); + setMarkStyle((prev) => ({ ...prev, size: Number(v) })); + }, }, gpsColor: { label: 'COLOR', off: '#f59e0b', value: gpsStyle.color, options: WM_COLORS, - onPick: (v) => setGpsStyle((prev) => ({ ...prev, color: v })), + onPick: (v) => { + remember(); + setGpsStyle((prev) => ({ ...prev, color: v })); + }, }, gpsName: { label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], - onPick: (v) => setGpsStyle((prev) => ({ ...prev, showName: v === 'on' })), + onPick: (v) => { + remember(); + setGpsStyle((prev) => ({ ...prev, showName: v === 'on' })); + }, }, gpsTime: { label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], - onPick: (v) => setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' })), + onPick: (v) => { + remember(); + setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' })); + }, }, }; @@ -630,14 +820,20 @@ export function Workspace() { label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, disabled: !gps && !(gpsManual.lat && gpsManual.lng), - onClick: () => setUseGeotag(!useGeotag), + onClick: () => { + remember(); + setUseGeotag(!useGeotag); + }, }, ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), { key: 'custom-wm', label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: markOn, - onClick: () => setMarkOn(!markOn), + onClick: () => { + remember(); + setMarkOn(!markOn); + }, }, ...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []), ]; @@ -681,8 +877,6 @@ export function Workspace() { }, })), groupChip('crop'), - // ponytail: no APPLY — picking a ratio crops immediately, there is no - // framed-but-uncommitted state on the web yet. { key: 'grp-rotate', label: `ROTATE ${rotation}`, @@ -715,8 +909,8 @@ export function Workspace() { setShowRecipes((v) => !v); }, }, - // ponytail: no CREATE/IMPORT chips — saving goes through the top bar - // and the web build has no recipe import. + // ponytail: no CREATE chip — saving goes through the top bar. IMPORT + // is a chip inside the RECIPES strip. ]; case 'favorited': return saved.length === 0 @@ -759,10 +953,20 @@ export function Workspace() { const stripChips = (g: Exclude): ChipDef[] => [ { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick), + // The frame is on the photo but the crop is not in force until APPLY. + ...(g === 'crop' && cropEditing + ? [ + { key: 'crop-apply', label: t('crop.apply'), amberValue: true, onClick: applyCrop }, + { key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop }, + ] + : []), ]; // RECIPES = what this session made (newest first) + the bundled presets. + // IMPORT takes the .recipe file the phone (or another account) exports — same + // envelope, same parser, so a look crosses devices intact. const recipeStrip: ChipDef[] = [ + { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile }, ...created.map((entry) => ({ key: entry.id, label: entry.name, @@ -814,11 +1018,8 @@ export function Workspace() { {/* 3. PRESETS -> RECIPES strip */} {tab === 'presets' && showRecipes ? (
- {recipeStrip.length === 0 ? ( -

{t('sec.savedEmpty')}

- ) : ( - - )} + {recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null} +
) : null} @@ -902,6 +1103,10 @@ export function Workspace() { peek={peek} onPeek={setPeek} onFile={(file) => void loadFile(file)} + cropEditing={cropEditing} + cropRect={cropRect} + cropWH={cropWH} + onCropRectChange={setCropRect} /> diff --git a/docker/frontend/src/engine/session.ts b/docker/frontend/src/engine/session.ts index b1de73f..43ccad6 100644 --- a/docker/frontend/src/engine/session.ts +++ b/docker/frontend/src/engine/session.ts @@ -1,4 +1,4 @@ -import type { CropRatio, FrameId, GPSInfo, Recipe } from '../../shared/types'; +import type { CropRatio, CropRect, FrameId, GPSInfo, Recipe } from '../../shared/types'; // Studio session: the working photo plus every knob the workspace holds, so a // reload resumes exactly where the user left off — guest or signed in (the @@ -15,7 +15,10 @@ export interface StudioState { recipe?: Recipe; simId?: string | null; frameId?: FrameId; - crop?: Exclude; + crop?: CropRatio; + cropRect?: CropRect; + // false = the frame is on screen but not accepted yet (still draggable). + cropApplied?: boolean; rotation?: 0 | 90 | 180 | 270; gps?: GPSInfo | null; gpsManual?: { lat: string; lng: string }; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index cc73e81..7a8bf84 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -69,6 +69,8 @@ export const en: Dict = { 'crop.4:3': '4:3', 'crop.16:9': '16:9', 'crop.free': 'FREE', + 'crop.apply': 'APPLY', + 'crop.cancel': 'CANCEL', 'rot.0': '0°', 'rot.90': '90°', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 53ab3f7..794235c 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -72,6 +72,8 @@ export const vi = { 'crop.4:3': '4:3', 'crop.16:9': '16:9', 'crop.free': 'TỰ DO', + 'crop.apply': 'ÁP DỤNG', + 'crop.cancel': 'HUỶ', 'rot.0': '0°', 'rot.90': '90°', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 5e292db..b79b28b 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -171,6 +171,15 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; } .canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; } .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. */ +.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; } +.crop-handle.tl { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; } +.crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; } +.crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; } +.crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; } .busy { position: absolute; inset: auto 0 12px 0; diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 822e6f4..51b8c19 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; +import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; // The centre column: drop target, preview canvas, and the PEEK comparison. // Dragging a photo anywhere on the page works, not just on the frame — the @@ -12,6 +13,10 @@ export function ImageStage({ peek, onPeek, onFile, + cropEditing = false, + cropRect, + cropWH = null, + onCropRectChange, }: { previewUrl: string | null; originalUrl: string | null; @@ -20,10 +25,22 @@ export function ImageStage({ peek: boolean; onPeek: (on: boolean) => void; onFile: (file: File) => void; + // Crop editor: shown over the un-cropped preview until APPLY commits it. + cropEditing?: boolean; + cropRect?: CropRect; + // Wanted pixel aspect (w/h) for a fixed ratio, null for FREE. + cropWH?: number | null; + onCropRectChange?: (r: CropRect) => void; }) { const { t } = useI18n(); const inputRef = useRef(null); + const wrapRef = useRef(null); + const imgRef = useRef(null); const [over, setOver] = useState(false); + // The crop frame is measured off the img's own box, so it lands on the photo + // whatever the stage does with the space around it. + const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null); + const dragRef = useRef<{ mode: string; rect: CropRect; px: number; py: number } | null>(null); // \ toggles PEEK; the key is the app's own shortcut, kept identical on web. useEffect(() => { @@ -56,6 +73,86 @@ export function ImageStage({ [onFile, stop] ); + const measure = useCallback(() => { + const img = imgRef.current; + const wrap = wrapRef.current; + if (!img || !wrap) return setBox(null); + const i = img.getBoundingClientRect(); + const w = wrap.getBoundingClientRect(); + if (!(i.width > 0 && i.height > 0)) return setBox(null); + setBox({ left: i.left - w.left, top: i.top - w.top, width: i.width, height: i.height }); + }, []); + + useEffect(() => { + if (!cropEditing) { + setBox(null); + return; + } + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [cropEditing, measure, previewUrl]); + + const beginDrag = (mode: string) => (e: React.PointerEvent) => { + if (!onCropRectChange || !cropRect) return; + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { mode, rect: cropRect, px: e.clientX, py: e.clientY }; + }; + + // Fractions of the PHOTO, not of the screen: the export reads the same + // numbers, so what the frame encloses is what comes out. A fixed ratio keeps + // w/h locked to the wanted pixel aspect restated in fraction space — + // (w*dw)/(h*dh) = ratio, so w/h = ratio / (dw/dh). + const moveDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + const img = imgRef.current; + if (!d || !img || !onCropRectChange) return; + const b = img.getBoundingClientRect(); + if (!(b.width > 0 && b.height > 0)) return; + e.preventDefault(); + const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + const r = d.rect; + if (d.mode === 'move') { + onCropRectChange({ + x: cl(r.x + (e.clientX - d.px) / b.width, 0, 1 - r.w), + y: cl(r.y + (e.clientY - d.py) / b.height, 0, 1 - r.h), + w: r.w, + h: r.h, + }); + return; + } + const fx = cl((e.clientX - b.left) / b.width, 0, 1); + const fy = cl((e.clientY - b.top) / b.height, 0, 1); + // The opposite corner stays put: it is the anchor the frame grows from. + const ax = d.mode.includes('r') ? r.x : r.x + r.w; + const ay = d.mode.includes('b') ? r.y : r.y + r.h; + let w = Math.abs(fx - ax); + let h = Math.abs(fy - ay); + if (cropWH != null) { + const rr = cropWH / (b.width / b.height); + h = Math.max(h, w / rr); + w = h * rr; + if (w > 1) { w = 1; h = w / rr; } + if (h > 1) { h = 1; w = h * rr; } + } + w = cl(w, MIN_CROP_FRAC, 1); + h = cl(h, MIN_CROP_FRAC, 1); + onCropRectChange({ + x: cl(d.mode.includes('r') ? ax : ax - w, 0, 1 - w), + y: cl(d.mode.includes('b') ? ay : ay - h, 0, 1 - h), + w, + h, + }); + }; + + const endDrag = (e: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }; + return (
{t('drop.demoLimit', { px: 2048 })}

) : ( -
- preview +
+ {/* draggable={false}: a draggable preview hands the browser the graded + image as a File, and the stage's own drop handler then reads it + back as a new photo — the look re-applied once per drag. */} + preview + {cropEditing && box && cropRect && onCropRectChange ? ( +
+
+ {['tl', 'tr', 'bl', 'br'].map((h) => ( + + ))} +
+
+ ) : null} {peek ?
{t('drop.peeking')}
: null}
)}