feat(studio): edits follow the photo — reopening it from LIBRARY restores the whole look
Settings changed in the studio were remembered per photo, but only as a film recipe, so everything outside that recipe (straighten, flips, crop, watermark, geotag, frame, the picked sim) was gone as soon as the photo was reopened from the library in a later session. Keep the whole StudioState instead and restore it when a library photo is opened; rows written by older builds — a flat recipe — are still readable, so photos edited before this change open with their look intact. SAVE PHOTO is untouched: that remains the step that publishes a photo to the landing film strip.
This commit is contained in:
+50
-14
@@ -23,7 +23,7 @@ import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
||||
import { offerInstall } from './pwa/install';
|
||||
import { cameraSupported } from './engine/camera';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
import { api, type Photo, type SavedRecipe } from './api';
|
||||
import { refreshAuth, useAuth } from './auth';
|
||||
@@ -466,12 +466,17 @@ export function Workspace() {
|
||||
// the box's own position falls back to the default instead of going undefined.
|
||||
const [markStyle, setMarkStyle] = useState<MarkStyle>(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle }));
|
||||
|
||||
// Every knob the workspace holds, written back on change — the reload half of
|
||||
// the session. Small JSON, so no debounce is worth the risk of losing the last
|
||||
// Every knob the workspace holds, gathered once — the reload half of the
|
||||
// session and the look a catalogue frame is filed at both write this same
|
||||
// record. Small JSON, so no debounce is worth the risk of losing the last
|
||||
// edit; the photo has its own store (engine/session.ts).
|
||||
const studioState = useMemo<StudioState>(
|
||||
() => ({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }),
|
||||
[recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle],
|
||||
);
|
||||
useEffect(() => {
|
||||
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
|
||||
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
|
||||
saveState(studioState);
|
||||
}, [studioState]);
|
||||
|
||||
// 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).
|
||||
@@ -601,6 +606,35 @@ export function Workspace() {
|
||||
gpsStyle: { ...DEFAULT_GPS_STYLE },
|
||||
};
|
||||
}, []);
|
||||
// A catalogue frame opened again comes back on exactly the look it was left
|
||||
// at: restoreLook for everything UNDO remembers, and the three GPS knobs Look
|
||||
// leaves out (they live on the session, not in the undo stack).
|
||||
const restoreState = useCallback(
|
||||
(s: StudioState) => {
|
||||
const d = defaultLook();
|
||||
restoreLook({
|
||||
recipe: s.recipe ?? d.recipe,
|
||||
simId: s.simId ?? d.simId,
|
||||
frameId: s.frameId ?? d.frameId,
|
||||
crop: s.crop ?? d.crop,
|
||||
cropRect: s.cropRect ?? d.cropRect,
|
||||
cropApplied: s.cropApplied ?? d.cropApplied,
|
||||
rotation: s.rotation ?? d.rotation,
|
||||
straighten: s.straighten ?? d.straighten,
|
||||
flipH: s.flipH ?? d.flipH,
|
||||
flipV: s.flipV ?? d.flipV,
|
||||
markOn: s.markOn ?? d.markOn,
|
||||
markText: s.markText ?? d.markText,
|
||||
markStyle: { ...DEFAULT_MARK_STYLE, ...s.markStyle },
|
||||
useGeotag: s.useGeotag ?? d.useGeotag,
|
||||
gpsStyle: { ...DEFAULT_GPS_STYLE, ...s.gpsStyle },
|
||||
});
|
||||
setGps(s.gps ?? null);
|
||||
setGpsManual(s.gpsManual ?? { lat: '', lng: '' });
|
||||
setPlace(s.place ?? '');
|
||||
},
|
||||
[restoreLook, defaultLook],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// The export menu: the sizes are picked before the render starts, because a
|
||||
// target bigger than the photo has a model run behind it first.
|
||||
@@ -2112,9 +2146,9 @@ export function Workspace() {
|
||||
);
|
||||
|
||||
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
|
||||
// come off the disk through the handle the catalogue kept, then the recipe that
|
||||
// frame was last left at goes on top. A frame first opened has no recipe yet
|
||||
// and arrives on the neutral stock, like any other file.
|
||||
// come off the disk through the handle the catalogue kept, then the look that
|
||||
// frame was last left at goes on top. A frame first opened has no look yet and
|
||||
// arrives on the neutral stock, like any other file.
|
||||
const openLibraryPhoto = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(t('auth.busy'));
|
||||
@@ -2129,7 +2163,7 @@ export function Workspace() {
|
||||
if (!row) throw new Error('not in the catalogue');
|
||||
await loadFile(await readPhotoFile(row));
|
||||
const saved = await loadEdit(id);
|
||||
if (saved) applyRecipe(saved.recipe);
|
||||
if (saved) restoreState(saved.state);
|
||||
// The catalogue's own standing for this frame, which the reader set on
|
||||
// the library's stage: the studio opens the negative the way they last
|
||||
// saw it, at the same angle. It arrives after `loadFile`, which puts the
|
||||
@@ -2142,7 +2176,7 @@ export function Workspace() {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[loadFile, applyRecipe, clearPreview, t]
|
||||
[loadFile, restoreState, clearPreview, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2150,13 +2184,15 @@ export function Workspace() {
|
||||
}, [openLibraryPhoto]);
|
||||
|
||||
// What the catalogue remembers about a frame is the look it was left at, filed
|
||||
// back on every change — debounced, because a slider drag fires this per frame
|
||||
// of the drag and IndexedDB is on the wrong side of that.
|
||||
// back on every change — the whole look, not just the recipe, so the next
|
||||
// session reopens the frame cropped, straightened and marked as it was left.
|
||||
// Debounced, because a slider drag fires this per frame of the drag and
|
||||
// IndexedDB is on the wrong side of that.
|
||||
useEffect(() => {
|
||||
if (!libPhotoId || !source) return;
|
||||
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
|
||||
const timer = setTimeout(() => void saveEdit(libPhotoId, studioState), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [libPhotoId, source, recipe]);
|
||||
}, [libPhotoId, source, studioState]);
|
||||
|
||||
// The same filing for the frame's own standing: the catalogue opened this
|
||||
// frame at an angle, and a turn made here is that number's to keep — the
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// FileSystemHandle by itself and the whole surface is seven calls — a dependency
|
||||
// does not pay for itself here. Revisit if the catalogue grows queries (tags,
|
||||
// smart collections) that hand-rolled cursors would make ugly.
|
||||
import type { Recipe } from '../../shared/types';
|
||||
import type { StudioState } from './session';
|
||||
import { readCapturedAt } from './imageOps';
|
||||
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
||||
@@ -131,7 +131,10 @@ export interface LibraryDir {
|
||||
|
||||
export interface LibraryEdit {
|
||||
photoId: string;
|
||||
recipe: Recipe;
|
||||
// The whole look the frame was left at, the same record the studio keeps for
|
||||
// the working photo — frame, crop, rotation, mirrors, marks and geotag change
|
||||
// the picture as much as the recipe does, so the recipe alone was not enough.
|
||||
state: StudioState;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -2150,18 +2153,27 @@ export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||||
|
||||
// --- edits -----------------------------------------------------------------
|
||||
|
||||
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
|
||||
export async function saveEdit(id: string, state: StudioState): Promise<void> {
|
||||
try {
|
||||
const edit: LibraryEdit = { photoId: id, recipe, updatedAt: Date.now() };
|
||||
const edit: LibraryEdit = { photoId: id, state, updatedAt: Date.now() };
|
||||
await ask(EDITS, 'readwrite', (s) => s.put(edit));
|
||||
} catch {
|
||||
// Private mode: the frame is still editable, it just forgets the recipe.
|
||||
// Private mode: the frame is still editable, it just forgets the look.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
|
||||
try {
|
||||
return (await ask<LibraryEdit | undefined>(EDITS, 'readonly', (s) => s.get(id))) ?? null;
|
||||
// A row filed before the whole look was kept carries the recipe alone: lift
|
||||
// it into a state so the caller reads one shape, old rows included.
|
||||
const row = await ask<(Partial<LibraryEdit> & { recipe?: StudioState['recipe'] }) | undefined>(
|
||||
EDITS,
|
||||
'readonly',
|
||||
(s) => s.get(id),
|
||||
);
|
||||
if (!row) return null;
|
||||
if (row.state) return row as LibraryEdit;
|
||||
return { photoId: row.photoId ?? id, state: { recipe: row.recipe }, updatedAt: row.updatedAt ?? 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user