Add a draggable crop frame behind APPLY, whole-look undo, and recipe import

This commit is contained in:
2026-09-17 20:14:21 +07:00
parent cc313f2ea9
commit 751beb52c1
6 changed files with 430 additions and 69 deletions
+270 -65
View File
@@ -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<CropRatio, 'free'>[] = ['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<string | null>(() => session.simId ?? null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>(() => session.crop ?? 'none');
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => 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<string | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(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<Recipe[]>([]);
// 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<Look[]>([]);
const lookRef = useRef<Look | null>(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<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
@@ -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<Recipe['adjustments']>) => {
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 <input type=file> 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<CropRatio, 'free'>),
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<GroupKey, 'wm'>): 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 ? (
<div className="strip">
{recipeStrip.length === 0 ? (
<p className="hint">{t('sec.savedEmpty')}</p>
) : (
<ChipRow chips={recipeStrip} />
)}
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : 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}
/>
</div>
+5 -2
View File
@@ -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<CropRatio, 'free'>;
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 };
+2
View File
@@ -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°',
+2
View File
@@ -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°',
+9
View File
@@ -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;
+142 -2
View File
@@ -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<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
return (
<div
className="stage"
@@ -86,8 +183,51 @@ export function ImageStage({
<p className="hint">{t('drop.demoLimit', { px: 2048 })}</p>
</div>
) : (
<div className="canvas-wrap">
<img src={peek && originalUrl ? originalUrl : previewUrl} alt="preview" />
<div className="canvas-wrap" ref={wrapRef}>
{/* 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. */}
<img
ref={imgRef}
src={peek && originalUrl ? originalUrl : previewUrl}
alt="preview"
draggable={false}
onLoad={measure}
/>
{cropEditing && box && cropRect && onCropRectChange ? (
<div
className="crop-layer"
data-key="crop-layer"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
>
<div
className="crop-rect"
data-key="crop-rect"
style={{
left: `${cropRect.x * 100}%`,
top: `${cropRect.y * 100}%`,
width: `${cropRect.w * 100}%`,
height: `${cropRect.h * 100}%`,
}}
onPointerDown={beginDrag('move')}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{['tl', 'tr', 'bl', 'br'].map((h) => (
<span
key={h}
className={`crop-handle ${h}`}
data-key={`crop-handle-${h}`}
onPointerDown={beginDrag(h)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
))}
</div>
</div>
) : null}
{peek ? <div className="busy">{t('drop.peeking')}</div> : null}
</div>
)}