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:
+127
-37
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user