Add a draggable crop frame behind APPLY, whole-look undo, and recipe import
This commit is contained in:
+270
-65
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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°',
|
||||
|
||||
@@ -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°',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user