Files
RecipesCam/docker/frontend/src/engine/session.ts
T
3dtours d55b7b49ca web: give each watermark its own collapse, and a face to print in
The panel shared one column between the two marks, so GPS's colour, its two
switches and its hand-typed place stood open beside the custom mark's text,
colour and size whether or not either mark was on. The two are now collapses,
one per mark: the header chip is the section, and that mark's own controls sit
under it. What opens a section is the mark itself — GPS WATERMARK ON opens
GPS's controls, CUSTOM WATERMARK ON opens the custom mark's — so there is no
new state and no way for a panel to disagree with the pixels.

Both marks gain the FONT strip the phone has had (TEXT FONT for the custom
mark, FONT for GPS, whose stamp the phone also lets you set a face on). A
browser has no font service, so the list is exactly what the bundle carries:
the site's two self-hosted families, Inter and Fraunces (SIL OFL), their latin,
latin-ext and vietnamese woff2 subsets decompressed, pinned to weight 400 @
opsz 14 and merged into ONE TTF per family — drawText has no glyph fallback, so
a family mapped to only the latin subset would print a Vietnamese place name as
tofu. DEFAULT stays the bundled Cousine face, which is what every existing
session and every mark without a family prints.

Two engine bugs came out of it. CanvasKit 0.42's Font.getGlyphWidths passes its
output pointer where the wasm export wants the bounds pointer, so every glyph in
a run comes back holding one identical, rounded width — at 64px on the merged
Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and 0.242em,
and a box measured off it was 27% too wide ("Hà Nội 09/23" 510px against a true
403px). The shim now rebinds it with the pointers in the order
_getGlyphWidthBounds reads them, and the stage's boxes measure with linear
metrics, which land on hmtx exactly (403.28px against 403.28; hinted is 407).
And CanvasKit's TypefaceFontProvider.matchFamilyStyle answers null for every
style shape this binding accepts, so a name registered with it never resolved —
the shim keeps its own registry keyed by family name instead.

Measured: tsc clean; the engine harness on the merged faces 26/26, including the
registry's advances against hmtx (Inter 6.3013em, Fraunces 6.3475em); the
deployed app under Playwright 38/38 over the two collapses and both FONT strips
— each mark's controls appear only with its own mark on, the DEFAULT/INTER/
FRAUNCES box widths match hmtx, the baked ink fills the box, the top edge
re-hangs off the new ascent (Inter 0.96875em against Cousine's 0.8325em, 3.4px
at this size) with the left edge fixed, and UNDO round-trips. Opening a section
narrows the stage by 168px with no window resize (955px -> 787px), so the stage
now re-measures its drop boxes off a ResizeObserver on the frame and the
picture rather than on the next render.

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
2026-09-23 10:59:49 +07:00

135 lines
4.8 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;
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.
}
}