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}
+11
View File
@@ -93,6 +93,17 @@ export async function loadPhoto(): Promise<{ name: string; bytes: Uint8Array } |
}
}
// CLEAR takes the working photo off the stage but keeps the knobs: the look was
// the user's work and outlives the picture it was made on. The session's own
// copy has to go with it, or the next reload would put the frame straight back.
export async function forgetPhoto(): Promise<void> {
try {
await idb('readwrite', (s) => s.delete(PHOTO_KEY));
} catch {
// Private mode / no IndexedDB: the photo just will not survive a reload.
}
}
// Signing out ends the session: drop the knobs and the working photo, so the
// next visit — guest or another account — opens the studio empty. The account's
// own photos stay in MY PHOTOS, ready to reopen.
+11 -1
View File
@@ -22,7 +22,8 @@ export const en: Dict = {
'drop.title': 'Drag & drop a photo here',
'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V',
'drop.replace': 'Choose another photo',
'drop.replace': 'Open photo',
'drop.clear': 'Clear',
'drop.peeking': 'SHOWING THE ORIGINAL',
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
'drop.compare': 'Compare with original picture',
@@ -164,6 +165,15 @@ export const en: Dict = {
'del.confirm': 'DELETE PHOTO',
'del.cancel': 'CANCEL',
// CLEAR's confirmation: keep the work one of two ways, or let it go.
'clear.title': 'Clear this photo?',
'clear.hint':
'The photo leaves the workspace. Save it to MY PHOTOS to carry on editing later, export it as an image file, or clear it without keeping anything.',
'clear.save': 'SAVE PHOTO',
'clear.export': 'EXPORT IMAGE',
'clear.discard': 'CLEAR WITHOUT SAVING',
'clear.cancel': 'CANCEL',
'photo.uploading': 'UPLOADING…',
'photos.title': 'My photos',
+11 -1
View File
@@ -29,7 +29,8 @@ export const vi = {
'drop.title': 'Kéo & thả ảnh vào đây',
'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V',
'drop.replace': 'Đổi ảnh khác',
'drop.replace': 'Mở ảnh',
'drop.clear': 'Xoá ảnh',
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
'drop.compare': 'So với ảnh gốc',
@@ -172,6 +173,15 @@ export const vi = {
'del.confirm': 'XOÁ ẢNH',
'del.cancel': 'HUỶ',
// Xác nhận của CLEAR: giữ lại thành quả theo một trong hai cách, hoặc bỏ hẳn.
'clear.title': 'Xoá ảnh đang mở?',
'clear.hint':
'Ảnh sẽ rời khỏi màn hình làm việc. Lưu vào ẢNH CỦA TÔI để lần sau sửa tiếp, xuất ra tệp ảnh, hoặc xoá mà không giữ lại gì.',
'clear.save': 'LƯU ẢNH',
'clear.export': 'XUẤT ẢNH',
'clear.discard': 'XOÁ KHÔNG LƯU',
'clear.cancel': 'HUỶ',
'photo.uploading': 'ĐANG TẢI…',
'photos.title': 'Ảnh của tôi',
@@ -0,0 +1,44 @@
import { useI18n } from '../i18n/I18nProvider';
// CLEAR takes the photo off the stage, and the work standing on it goes with
// it: the look lives in the session, not in the picture. So the button asks
// first, and the two ways of keeping something are one tap each — file the
// frame in MY PHOTOS so it can be picked up again, or put a JPEG in the user's
// hands. Neither is forced: CLEAR WITHOUT SAVING is a plain discard, and
// closing the dialog does nothing at all.
export function ClearPhotoModal({
onSave,
onExport,
onDiscard,
onClose,
}: {
onSave: () => void;
onExport: () => void;
onDiscard: () => void;
onClose: () => void;
}) {
const { t } = useI18n();
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" data-key="clear-modal">
<h2>{t('clear.title')}</h2>
<p className="hint" data-key="clear-hint">
{t('clear.hint')}
</p>
<button type="button" className="btn" data-key="clear-save" onClick={onSave}>
{t('clear.save')}
</button>
<button type="button" className="btn" data-key="clear-export" onClick={onExport}>
{t('clear.export')}
</button>
<button type="button" className="btn adm-danger" data-key="clear-discard" onClick={onDiscard}>
{t('clear.discard')}
</button>
<button type="button" className="btn ghost" data-key="clear-cancel" onClick={onClose}>
{t('clear.cancel')}
</button>
</div>
</div>
);
}
+5 -12
View File
@@ -68,7 +68,7 @@ function curve(bins: number[], max: number, close: boolean): string {
export function Histogram({ url, onClose }: { url: string; onClose: () => void }) {
const { t } = useI18n();
const [data, setData] = useState<Histogram | null>(null);
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [pos, setPos] = useState<{ x: number; y: number }>({ x: 12, y: 12 });
const [dragging, setDragging] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ px: number; py: number; x: number; y: number } | null>(null);
@@ -98,16 +98,6 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void }
return { x: Math.min(Math.max(8, x), hi(b.width, m.width)), y: Math.min(Math.max(8, y), hi(b.height, m.height)) };
}, []);
// First paint: park it in the top-right corner of the photo.
useEffect(() => {
if (pos) return;
const wrap = boxRef.current?.parentElement;
const box = boxRef.current;
if (!wrap || !box) return;
const b = wrap.getBoundingClientRect();
setPos({ x: Math.max(8, b.width - box.getBoundingClientRect().width - 12), y: 12 });
}, [pos]);
// The photo's own box is what the frame is parked against, and it shrinks
// when a column opens beside the stage — not only when the window does. So
// the frame watches that box, not the window, and is pulled back inside it.
@@ -151,12 +141,15 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void }
// that starts to hide the shadows.
const max = Math.max(1, ...(data ? [...data.lum, ...data.r, ...data.g, ...data.b] : [1]));
// The shape is parked in the photo's top-left corner as soon as it appears —
// that is where the eye already is when a frame lands — and the user drags it
// wherever they want it from there.
return (
<div
className={`hist${dragging ? ' dragging' : ''}`}
data-key="histogram"
ref={boxRef}
style={{ left: pos?.x ?? 12, top: pos?.y ?? 12, visibility: pos ? 'visible' : 'hidden' }}
style={{ left: pos.x, top: pos.y }}
>
<div
className="hist-head"
+10 -1
View File
@@ -66,6 +66,9 @@ export function ImageStage({
onStageZoom,
canCompare = false,
onSaveOriginal,
// Takes the photo off the stage. What is on it goes first — the confirmation
// offers to file the frame or export it, so nothing is lost by accident.
onClear,
picking = false,
onPickColor,
pickPanel = null,
@@ -96,6 +99,7 @@ export function ImageStage({
// Puts the untouched photo file itself in the owner's hands — on a phone that
// is the share sheet, whose "Save Image" is what reaches the photo library.
onSaveOriginal?: () => void;
onClear?: () => void;
// Eyedropper mode: clicks on the photo report the colour underneath instead of
// panning it. The HSL mixer's PICK chip owns this flag.
picking?: boolean;
@@ -831,7 +835,12 @@ export function ImageStage({
{previewUrl ? (
<div className="toolbar">
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
{onClear ? (
<button type="button" className="btn" data-key="clear-photo" onClick={onClear}>
{t('drop.clear')}
</button>
) : null}
<button type="button" className="btn" data-key="open-photo" onClick={() => inputRef.current?.click()}>
{t('drop.replace')}
</button>
{onSaveOriginal ? (