c70edce8c1
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates.
138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
import type { CropRatio, CropRect, FrameId, GPSInfo, Recipe } from '../../shared/types';
|
|
|
|
// Studio session: the working photo plus every knob the workspace holds, so a
|
|
// reload resumes exactly where the user left off — guest or signed in (the
|
|
// login cookie already outlives the tab; this covers the rest).
|
|
//
|
|
// ponytail: one session per browser, not per account — two accounts in the same
|
|
// browser share the working photo. Key it by user id if that ever matters.
|
|
const STATE_KEY = 'rc.studio.v1';
|
|
const DB_NAME = 'recipescam-studio';
|
|
const STORE = 'session';
|
|
const PHOTO_KEY = 'photo';
|
|
|
|
export interface StudioState {
|
|
recipe?: Recipe;
|
|
simId?: string | null;
|
|
frameId?: FrameId;
|
|
crop?: CropRatio;
|
|
cropRect?: CropRect;
|
|
// false = the frame is on screen but not accepted yet (still draggable).
|
|
cropApplied?: boolean;
|
|
rotation?: 0 | 90 | 180 | 270;
|
|
// FRAME's STRAIGHTEN: degrees off the quarter turn, -180..180.
|
|
straighten?: number;
|
|
// FRAME's two mirrors. Absent in older sessions -> no flip.
|
|
flipH?: boolean;
|
|
flipV?: boolean;
|
|
gps?: GPSInfo | null;
|
|
gpsManual?: { lat: string; lng: string };
|
|
place?: string;
|
|
useGeotag?: boolean;
|
|
// size/x/y are the GPS box on the photo; older sessions hold only the colour
|
|
// and the two switches and take the defaults for the rest. font is the family
|
|
// the FONT strip picked, null for the bundled face.
|
|
gpsStyle?: { color: string; showName: boolean; showTime: boolean; size?: number; x?: number; y?: number; font?: string | null };
|
|
markText?: string;
|
|
markOn?: boolean;
|
|
// x/y are fractions of the stamp the mark is drawn on (the drag box writes
|
|
// them straight back); older sessions hold no x/y and take the default.
|
|
markStyle?: { color: string; size: number; x?: number; y?: number; font?: string | null };
|
|
}
|
|
|
|
export function loadState(): StudioState {
|
|
try {
|
|
const raw = localStorage.getItem(STATE_KEY);
|
|
return raw ? (JSON.parse(raw) as StudioState) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function saveState(state: StudioState): void {
|
|
try {
|
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
|
} catch {
|
|
// Quota or private mode: the studio still works, it just forgets.
|
|
}
|
|
}
|
|
|
|
// --- the photo: IndexedDB, because a 12MP JPEG does not fit in localStorage ---
|
|
function openDb(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function idb<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T> {
|
|
const db = await openDb();
|
|
try {
|
|
return await new Promise<T>((resolve, reject) => {
|
|
const req = run(db.transaction(STORE, mode).objectStore(STORE));
|
|
req.onsuccess = () => resolve(req.result as T);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
export async function savePhoto(name: string, bytes: Uint8Array): Promise<void> {
|
|
try {
|
|
await idb('readwrite', (s) => s.put({ name, bytes }, PHOTO_KEY));
|
|
} catch {
|
|
// Private mode / no IndexedDB: the photo just will not survive a reload.
|
|
}
|
|
}
|
|
|
|
export async function loadPhoto(): Promise<{ name: string; bytes: Uint8Array } | null> {
|
|
try {
|
|
const row = await idb<{ name: string; bytes: Uint8Array } | undefined>('readonly', (s) => s.get(PHOTO_KEY));
|
|
return row && row.bytes instanceof Uint8Array ? { name: row.name, bytes: row.bytes } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Async on purpose: the caller reloads the moment this returns, and a reload
|
|
// aborts a delete still in flight. Resolve on the transaction's completion, not
|
|
// on the request's success, or the bytes outlive the logout.
|
|
export async function clearSession(): Promise<void> {
|
|
try {
|
|
localStorage.removeItem(STATE_KEY);
|
|
} catch {
|
|
// Nothing to clear.
|
|
}
|
|
try {
|
|
const db = await openDb();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).delete(PHOTO_KEY);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.onabort = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
} catch {
|
|
// Private mode / no IndexedDB: there is nothing stored to forget.
|
|
}
|
|
}
|