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 { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
|
||||||
import { offerInstall } from './pwa/install';
|
import { offerInstall } from './pwa/install';
|
||||||
import { cameraSupported } from './engine/camera';
|
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 { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||||
import { api, type Photo, type SavedRecipe } from './api';
|
import { api, type Photo, type SavedRecipe } from './api';
|
||||||
import { refreshAuth, useAuth } from './auth';
|
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.
|
// the box's own position falls back to the default instead of going undefined.
|
||||||
const [markStyle, setMarkStyle] = useState<MarkStyle>(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle }));
|
const [markStyle, setMarkStyle] = useState<MarkStyle>(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle }));
|
||||||
|
|
||||||
// Every knob the workspace holds, written back on change — the reload half of
|
// Every knob the workspace holds, gathered once — the reload half of the
|
||||||
// the session. Small JSON, so no debounce is worth the risk of losing the last
|
// 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).
|
// 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(() => {
|
useEffect(() => {
|
||||||
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
|
saveState(studioState);
|
||||||
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
|
}, [studioState]);
|
||||||
|
|
||||||
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
|
// 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).
|
// and the whole photo is shown), and applied (the crop alone is rendered).
|
||||||
@@ -601,6 +606,35 @@ export function Workspace() {
|
|||||||
gpsStyle: { ...DEFAULT_GPS_STYLE },
|
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);
|
const [exporting, setExporting] = useState(false);
|
||||||
// The export menu: the sizes are picked before the render starts, because a
|
// 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.
|
// 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
|
// 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
|
// 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 recipe yet
|
// frame was last left at goes on top. A frame first opened has no look yet and
|
||||||
// and arrives on the neutral stock, like any other file.
|
// arrives on the neutral stock, like any other file.
|
||||||
const openLibraryPhoto = useCallback(
|
const openLibraryPhoto = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
setBusy(t('auth.busy'));
|
setBusy(t('auth.busy'));
|
||||||
@@ -2129,7 +2163,7 @@ export function Workspace() {
|
|||||||
if (!row) throw new Error('not in the catalogue');
|
if (!row) throw new Error('not in the catalogue');
|
||||||
await loadFile(await readPhotoFile(row));
|
await loadFile(await readPhotoFile(row));
|
||||||
const saved = await loadEdit(id);
|
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 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
|
// 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
|
// saw it, at the same angle. It arrives after `loadFile`, which puts the
|
||||||
@@ -2142,7 +2176,7 @@ export function Workspace() {
|
|||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadFile, applyRecipe, clearPreview, t]
|
[loadFile, restoreState, clearPreview, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2150,13 +2184,15 @@ export function Workspace() {
|
|||||||
}, [openLibraryPhoto]);
|
}, [openLibraryPhoto]);
|
||||||
|
|
||||||
// What the catalogue remembers about a frame is the look it was left at, filed
|
// 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
|
// back on every change — the whole look, not just the recipe, so the next
|
||||||
// of the drag and IndexedDB is on the wrong side of that.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!libPhotoId || !source) return;
|
if (!libPhotoId || !source) return;
|
||||||
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
|
const timer = setTimeout(() => void saveEdit(libPhotoId, studioState), 600);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [libPhotoId, source, recipe]);
|
}, [libPhotoId, source, studioState]);
|
||||||
|
|
||||||
// The same filing for the frame's own standing: the catalogue opened this
|
// 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
|
// 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
|
// 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,
|
// does not pay for itself here. Revisit if the catalogue grows queries (tags,
|
||||||
// smart collections) that hand-rolled cursors would make ugly.
|
// 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 { readCapturedAt } from './imageOps';
|
||||||
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
||||||
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
||||||
@@ -131,7 +131,10 @@ export interface LibraryDir {
|
|||||||
|
|
||||||
export interface LibraryEdit {
|
export interface LibraryEdit {
|
||||||
photoId: string;
|
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;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2150,18 +2153,27 @@ export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
|||||||
|
|
||||||
// --- edits -----------------------------------------------------------------
|
// --- edits -----------------------------------------------------------------
|
||||||
|
|
||||||
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
|
export async function saveEdit(id: string, state: StudioState): Promise<void> {
|
||||||
try {
|
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));
|
await ask(EDITS, 'readwrite', (s) => s.put(edit));
|
||||||
} catch {
|
} 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> {
|
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user