web: the histogram opens top-left, and CLEAR asks before it forgets

The histogram used to park itself in the top-right corner on the first
paint; it now starts at the top-left of the photo and is dragged from
there, the way the rest of the overlay is. Nothing else changed in it —
same drag, same clamping, same resize.

The stage also gains a CLEAR button, sitting before the picker button,
which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not
before asking: SAVE PHOTO files it first and only then clears, EXPORT
IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it
there and then, and CANCEL leaves everything alone. Saving from that
modal resumes the clear once the file has really landed — a guest, a
capped account or a cancelled name prompt never loses the frame.

Clearing forgets the working photo (source, preview, GPS, ISO, and the
IndexedDB copy session.ts now deletes), while the look, the crop and the
undo history stay put, so the next photo opens on the same settings the
way replacing a photo already did.
This commit is contained in:
2026-09-22 08:56:58 +07:00
parent 59d90ae068
commit f9a40a9e1c
7 changed files with 183 additions and 26 deletions
+91 -11
View File
@@ -7,12 +7,13 @@ import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
import { DeletePhotoModal } from './ui/DeletePhotoModal';
import { ClearPhotoModal } from './ui/ClearPhotoModal';
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps';
import { clearSession, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { api, type Photo, type SavedRecipe, type User } from './api';
import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
@@ -424,6 +425,9 @@ export function Workspace() {
// The saved photo whose removal is being confirmed, if any — its own history
// goes with it, so the dialog says so and offers a download first.
const [removing, setRemoving] = useState<Photo | null>(null);
// CLEAR's confirmation is up: the photo is still on the stage until one of its
// answers is taken.
const [clearing, setClearing] = useState(false);
// null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in
// tab (sign-in is the default; ?auth=signup asks for the other one); the
// landing page runs the same dialog in place instead of linking here.
@@ -572,6 +576,30 @@ export function Workspace() {
});
}, []);
// CLEAR: the photo leaves the stage, the look stays. The knobs, the sim, the
// frame and the stamps are the user's work and were made on this picture, not
// by it — so they survive it, exactly as they survive opening another photo.
// What goes is what the picture itself brought: its pixels, its shape, its
// EXIF position and ISO. The session's own copy goes too (forgetPhoto), or
// the next reload would put the frame straight back.
const clearPhoto = useCallback(() => {
setSource((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return null;
});
clearPreview();
setPreviewBytes(null);
setPhotoDims(null);
setOpenPhotoId(null);
setGps(null);
setGpsManual({ lat: '', lng: '' });
setUseGeotag(false);
setIso(null);
setError(null);
setBusy(null);
void forgetPhoto();
}, [clearPreview]);
// What the phone app gets from the OS geocoder, the browser has no twin of,
// so the name behind a coordinate comes from the API. Fire and forget: the
// stamp is already readable without it, and a name that arrives late (or not
@@ -1085,11 +1113,13 @@ export function Workspace() {
[frameId, useGeotag, recipe.adjustments.hdf]
);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
// `true` when a file was handed over, so CLEAR knows the export it offered
// actually happened before the photo comes off the stage.
const exportPhoto = useCallback(async (): Promise<boolean> => {
if (!engine || !source) return false;
if (!pro && proLookInUse()) {
promptPro();
return;
return false;
}
setExporting(true);
setError(null);
@@ -1140,8 +1170,10 @@ export function Workspace() {
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
return true;
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
return false;
} finally {
setExporting(false);
}
@@ -1151,28 +1183,30 @@ export function Workspace() {
// The upload is left clean (no burned strip) so the landing reel can draw its
// own `.lp-shot` frame around it; the labels are stored alongside instead.
// The 12-photo cap is the API's, mirrored here so the button can say no.
const savePhotoToFolder = useCallback(async (photoName?: string) => {
// `true` once the row and its base are stored, so CLEAR only takes the photo
// off the stage when the save it offered really landed.
const savePhotoToFolder = useCallback(async (photoName?: string): Promise<boolean> => {
if (!pro) {
promptPro();
return;
return false;
}
if (!engine || !source) {
setError(t('save.photoNoSource'));
return;
return false;
}
// Re-saving the frame already on the stage replaces its own row, so the cap
// only stands in the way of a new one.
const open = myPhotos.find((p) => p.id === openPhotoId);
if (!open && photoCap !== null && myPhotos.length >= photoCap) {
setError(t('save.photoFull', { max: photoCap }));
return;
return false;
}
// A frame being filed for the first time is asked for a name — the folder
// keeps it, and the landing reel draws it as the frame's title. A re-save
// keeps the name it already has, so it never asks twice.
if (!open && photoName === undefined) {
setNaming('photo');
return;
return false;
}
// A re-save keeps the name the frame already has — the folder's title, not
// the recipe it happens to be standing in.
@@ -1250,13 +1284,37 @@ export function Workspace() {
// The stage now stands on the newest look of this frame, so no earlier
// one in its history is the picked one.
setHistPick(null);
return true;
} catch (err) {
setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) }));
return false;
} finally {
setSavingPhoto(false);
}
}, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
// CLEAR's "keep it" answers: the save lands and the photo goes, or the export
// hands a file over and then it goes. Neither forces the hand — a save that
// bails (a guest without an account, the photo cap) leaves the frame standing,
// and so does an export that never produced a file.
const clearAfterRef = useRef(false);
const saveThenMaybeClear = useCallback(
async (photoName?: string) => {
const saved = await savePhotoToFolder(photoName);
// The name prompt can stand between the button and the save, so the
// intent is read where the save finishes rather than where it started.
if (saved && clearAfterRef.current) {
clearAfterRef.current = false;
clearPhoto();
}
return saved;
},
[savePhotoToFolder, clearPhoto]
);
const exportThenClear = useCallback(async () => {
if (await exportPhoto()) clearPhoto();
}, [exportPhoto, clearPhoto]);
// The original file, handed back byte for byte — the untouched frame the
// camera took, never the render. A page cannot write to the camera roll, so
// on a phone the file goes to the share sheet and "Save Image" on it is the
@@ -2064,7 +2122,7 @@ export function Workspace() {
onUndo={undoLast}
onRedo={redoLast}
onReset={reset}
onSavePhoto={() => void savePhotoToFolder()}
onSavePhoto={() => void saveThenMaybeClear()}
onExport={exportPhoto}
photoCount={myPhotos.length}
photoMax={photoCap}
@@ -2370,6 +2428,7 @@ export function Workspace() {
onStageZoom={onStageZoom}
canCompare={crop === 'none'}
onSaveOriginal={() => void saveOriginal()}
onClear={() => setClearing(true)}
picking={picking}
onPickColor={pickColor}
pickPanel={pickPanel}
@@ -2442,12 +2501,33 @@ export function Workspace() {
initial={strip.title}
onSubmit={(name) => {
setNaming(null);
void savePhotoToFolder(name);
void saveThenMaybeClear(name);
}}
onClose={() => setNaming(null)}
/>
) : null}
{clearing ? (
<ClearPhotoModal
onSave={() => {
setClearing(false);
// The save may still have to ask for a name; the flag makes that
// save the one that takes the photo off the stage.
clearAfterRef.current = true;
void saveThenMaybeClear();
}}
onExport={() => {
setClearing(false);
void exportThenClear();
}}
onDiscard={() => {
setClearing(false);
clearPhoto();
}}
onClose={() => setClearing(false)}
/>
) : null}
{removing ? (
<DeletePhotoModal
id={removing.id}