web: undo/redo, a clean preview, and a histogram that stays put

Four things the studio owed the visitor:
- UNDO/REDO in the header, so a look can be taken back and put back without
  reloading the photo; a fresh edit clears the redo trail.
- Opening a saved frame, or picking a look out of its history, now drops the
  stale preview buffer instead of leaving the previous render on the stage.
- The picked history look is marked in the accent, so it is plain which look
  the photo is wearing.
- The histogram is re-clamped against the photo box on resize, so opening a
  chip column no longer pushes the overlay past the edge of the canvas.

The 'NEW SAVES: FILM STRIP' chip goes: a save already lands in the strip.
This commit is contained in:
2026-09-18 15:47:08 +07:00
parent 78fe2b097b
commit abaa980f93
4 changed files with 167 additions and 39 deletions
+127 -37
View File
@@ -5,6 +5,7 @@ import { ImageStage } from './ui/ImageStage';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
import { DeletePhotoModal } from './ui/DeletePhotoModal';
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine } from './engine/assets';
@@ -244,16 +245,32 @@ export function Workspace() {
)
);
// --- undo -----------------------------------------------------------------
// --- undo / redo ----------------------------------------------------------
// 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.
// The floor is the look the session loaded with; REDO holds what UNDO walked
// back over, up to the newest edit, and a fresh edit drops that branch.
const [undo, setUndo] = useState<Look[]>([]);
const [redo, setRedo] = useState<Look[]>([]);
// Which look of the open frame's own history is standing on the stage, if one
// was picked from its column; a fresh edit or another frame clears it.
const [histPick, setHistPick] = useState<number | null>(null);
const lookRef = useRef<Look | null>(null);
lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle };
// Twenty looks is enough to walk, but the oldest entry is the floor — the look
// the session loaded with — and is never the one trimmed, so UNDO always
// reaches the start of the session.
const pushUndo = (snap: Look) =>
setUndo((stack) => (stack.length > 19 ? [stack[0], ...stack.slice(-18), snap] : [...stack, snap]));
const remember = useCallback(() => {
const snap = lookRef.current;
if (snap) setUndo((stack) => [...stack.slice(-19), snap]);
if (!snap) return;
pushUndo(snap);
setRedo([]);
// The look on the stage is no longer the one that was picked, so the
// history column drops its mark.
setHistPick(null);
}, []);
const restoreLook = useCallback((l: Look) => {
@@ -300,9 +317,6 @@ export function Workspace() {
// a saved frame with the settings it was stored with; the count labels the
// save button. The API is the real cap — this only labels the button.
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
// Consent for the NEXT save: does it join the landing film strip? Sticky, so
// the answer survives several saves. A guest's frames never reach the reel.
const [shareToStrip, setShareToStrip] = useState(true);
// The saved photo standing on the stage, if any — its own consent chip shows.
const [openPhotoId, setOpenPhotoId] = useState<number | null>(null);
// Recipes made in this session, guest ones included. A guest's copy lives
@@ -311,6 +325,9 @@ export function Workspace() {
// Which save flow is asking for a name, if any: `photo` on the first filing
// of an uploaded frame, `recipe` on SAVE RECENT. One modal, two callers.
const [naming, setNaming] = useState<'photo' | 'recipe' | null>(null);
// 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);
// 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.
@@ -427,6 +444,16 @@ export function Workspace() {
// One photo, two ways in: a drop/pick (which also stores it for the next
// visit) and the session restore, whose params are already in state and so
// must not be overwritten by the photo's own EXIF.
// A frame arriving on the stage replaces whatever the preview was holding.
// The old render is thrown away FIRST, so the new one can never be painted
// over it: the buffer is empty before the load starts.
const clearPreview = useCallback(() => {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}, []);
const adoptPhoto = useCallback(
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
@@ -455,8 +482,10 @@ export function Workspace() {
setGps(found);
setIso(foundIso);
// 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.
// UNDO walks all the way back and still leaves the photo loaded. REDO has
// nothing to walk forward onto in a history that just began.
setUndo([defaultLook()]);
setRedo([]);
if (keepGeo) return;
setUseGeotag(!!found);
if (!found) setGpsManual({ lat: '', lng: '' });
@@ -555,15 +584,20 @@ export function Workspace() {
const applyCrop = useCallback(() => setCropApplied(true), []);
// One step back: the look standing now goes onto REDO and the stack's top
// comes back. CANCEL and the UNDO button are the same move.
const stepBack = useCallback(() => {
const last = undo[undo.length - 1];
const cur = lookRef.current;
if (!last || !cur) return;
restoreLook(last);
setUndo((stack) => stack.slice(0, -1));
setRedo((stack) => [...stack.slice(-19), cur]);
}, [undo, restoreLook]);
// 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 cancelCrop = useCallback(() => stepBack(), [stepBack]);
const reset = useCallback(() => {
remember();
@@ -575,13 +609,18 @@ export function Workspace() {
setShowRecipes(false);
}, [remember, restoreLook, defaultLook]);
const undoLast = useCallback(() => {
setUndo((stack) => {
const last = stack[stack.length - 1];
if (last) restoreLook(last);
return stack.slice(0, -1);
});
}, [restoreLook]);
// UNDO walks back to the look the session loaded with; REDO walks forward
// again over exactly what it stepped past, up to the newest edit.
const undoLast = useCallback(() => stepBack(), [stepBack]);
const redoLast = useCallback(() => {
const next = redo[redo.length - 1];
const cur = lookRef.current;
if (!next || !cur) return;
restoreLook(next);
setRedo((stack) => stack.slice(0, -1));
setUndo((stack) => [...stack, cur]);
}, [redo, restoreLook]);
// SAVE RECENT — its own tab, not a corner of CREATE RECIPES. The look standing
// on the stage (sim, WB, light, FX and the frame) is filed under a name this
@@ -852,7 +891,9 @@ export function Workspace() {
setNaming('photo');
return;
}
const labels = photoName ? { ...strip, title: photoName } : strip;
// A re-save keeps the name the frame already has — the folder's title, not
// the recipe it happens to be standing in.
const labels = { ...strip, title: open ? open.title ?? strip.title : (photoName ?? strip.title) };
setSavingPhoto(true);
setError(null);
try {
@@ -890,18 +931,21 @@ export function Workspace() {
});
setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p)));
} else {
const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings, consent: shareToStrip });
const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings });
// The list entry carries the recipe we just sent, so reopening works
// before the next /photos/mine refresh.
setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]);
setOpenPhotoId(photo.id);
}
// The stage now stands on the newest look of this frame, so no earlier
// one in its history is the picked one.
setHistPick(null);
} catch (err) {
setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setSavingPhoto(false);
}
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, shareToStrip, t]);
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, t]);
// Reopen one of this account's saved frames: the bytes come back as a File
// through the same door an upload uses, then the stored look goes on top.
@@ -909,6 +953,10 @@ export function Workspace() {
async (photo: Photo) => {
setBusy(t('auth.busy'));
setError(null);
// The stage is emptied before the fetch, not after it: the frame being
// reopened must not land on top of the one already standing there.
clearPreview();
setHistPick(null);
try {
const res = await fetch(api.photoUrl(photo.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -927,7 +975,7 @@ export function Workspace() {
setBusy(null);
}
},
[loadFile, applyRecipe, t]
[loadFile, applyRecipe, clearPreview, t]
);
// The saved frame's own switch. The reel reads `consent`, so the tick answers
@@ -945,6 +993,23 @@ export function Workspace() {
}
}, []);
// Removing a frame takes its row and the looks its history kept. Confirmed
// first (ui/DeletePhotoModal), never on a single click.
const removeMyPhoto = useCallback(
async (photo: Photo) => {
try {
await api.deletePhoto(photo.id);
setMyPhotos((prev) => prev.filter((p) => p.id !== photo.id));
if (openPhotoId === photo.id) setOpenPhotoId(null);
} catch (err) {
setError(t('err.generic', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setRemoving(null);
}
},
[openPhotoId, t]
);
// --- strips ---------------------------------------------------------------
// The cascade: a chip opens the next column, and the column it came from stays
// put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The
@@ -1300,18 +1365,12 @@ export function Workspace() {
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
}));
case 'photos': {
// MY PHOTOS: the account's own saved frames, newest first, plus the
// switch that decides how a NEW save lands. Opening one reopens it for a
// further edit; the open frame's own history and consent live in its own
// column (see the render).
const rows: ChipDef[] = [
{
key: 'strip-next',
label: t(shareToStrip ? 'myp.newOn' : 'myp.newOff'),
active: shareToStrip,
onClick: () => setShareToStrip((v) => !v),
},
];
// MY PHOTOS: the account's own saved frames, newest first. Opening one
// reopens it for a further edit; that frame's own history, consent and
// removal live in its own column (see the render). A save always joins
// the landing strip — the owner takes it off again with that switch, or
// the curator with the admin slot.
const rows: ChipDef[] = [];
if (myPhotos.length === 0) {
rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} });
} else {
@@ -1417,7 +1476,9 @@ export function Workspace() {
exporting={exporting}
savingPhoto={savingPhoto}
canUndo={undo.length > 0}
canRedo={redo.length > 0}
onUndo={undoLast}
onRedo={redoLast}
onReset={reset}
onSavePhoto={() => void savePhotoToFolder()}
onExport={exportPhoto}
@@ -1586,7 +1647,16 @@ export function Workspace() {
...openPhoto.history.map((look, i) => ({
key: `hist-${i}`,
label: look.name || `#${i + 1}`,
onClick: () => applyRecipe(look),
// The picked look is marked like the open recipe in the
// RECIPES list — accent, so it follows the theme.
active: histPick === i,
onClick: () => {
// An older look is loaded the same way a frame is: the
// preview goes first, then the look it names is painted.
clearPreview();
applyRecipe({ ...look });
setHistPick(i);
},
})),
]}
/>
@@ -1602,6 +1672,16 @@ export function Workspace() {
/>
<span>{t('myp.consent')}</span>
</label>
{/* The frame's own removal. The row goes, and with it the looks
its history kept, so the dialog asks before it happens. */}
<button
type="button"
className="btn adm-danger"
data-key="photo-remove"
onClick={() => setRemoving(openPhoto)}
>
{t('myp.remove')}
</button>
</div>
) : null}
@@ -1657,6 +1737,16 @@ export function Workspace() {
/>
) : null}
{removing ? (
<DeletePhotoModal
id={removing.id}
name={removing.title ?? `#${removing.id}`}
historyCount={removing.history?.length ?? 0}
onConfirm={() => void removeMyPhoto(removing)}
onClose={() => setRemoving(null)}
/>
) : null}
{authMode ? (
<AuthModal
initialMode={authMode}
+2
View File
@@ -103,6 +103,8 @@
/* In a column the chips stack: one chip per row, full width. */
.col .chip-row { flex-direction: column; align-items: stretch; }
.col .chip { justify-content: space-between; text-align: left; }
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
.wm-extra { border-right-color: var(--accent-line); }
.chip {
border: 1px solid var(--border);
+10 -1
View File
@@ -108,10 +108,19 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void }
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.
useEffect(() => {
const onResize = () => setPos((p) => (p ? clamp(p.x, p.y) : p));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
const wrap = boxRef.current?.parentElement;
const ro = wrap ? new ResizeObserver(onResize) : null;
ro?.observe(wrap as Element);
return () => {
window.removeEventListener('resize', onResize);
ro?.disconnect();
};
}, [clamp]);
const start = (e: React.PointerEvent<HTMLDivElement>) => {
+28 -1
View File
@@ -12,7 +12,9 @@ export function TopBar({
exporting,
savingPhoto,
canUndo,
canRedo,
onUndo,
onRedo,
onReset,
onSavePhoto,
onExport,
@@ -27,7 +29,9 @@ export function TopBar({
exporting: boolean;
savingPhoto: boolean;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
onReset: () => void;
onSavePhoto: () => void;
onExport: () => void;
@@ -63,7 +67,30 @@ export function TopBar({
<span className="recipe-name">{recipeName}</span>
<span className="spacer" />
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
{/* Both arrows are one icon wide, so the pair reads as one control: the
counter-clockwise arrow steps back, the clockwise one forward. */}
<button
type="button"
className="btn ghost icon"
data-key="undo"
disabled={!canUndo}
title={t('act.undo')}
aria-label={t('act.undo')}
onClick={onUndo}
>
↺
</button>
<button
type="button"
className="btn ghost icon"
data-key="redo"
disabled={!canRedo}
title={t('act.redo')}
aria-label={t('act.redo')}
onClick={onRedo}
>
↻
</button>
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
<button
type="button"