Pagedraw: session save/load core with server autosave (editor_2d)

Document schema v3.0 (adds name + per-node mediaId; token-first images
with inline base64 fallback; loaders accept 2.0 and 3.0), IndexedDB
local replica carrying the undo history stack and unsynced image Files,
and a sync controller with one-way flow: scene-store dirty subscription,
debounced replica + server saves, single-flight with trailing requeue,
exponential backoff, media upload write-back, and updated_at
base-revision freshness checks that pause autosave into a conflict
state (both sides backed up) when another device advanced the server
copy. Server rows are created only when the user names a session;
unnamed drafts are protected locally per account, with anonymous drafts
adopted on login and cross-account replicas never hydrated or pushed.
Webapp implements the persistence seam over ProjectsApi editor_2d; the
Tauri host is unaffected (seam is optional).
This commit is contained in:
Tanish Manku
2026-07-28 20:06:31 -07:00
parent a49426b77d
commit d5a7262cc7
11 changed files with 2034 additions and 69 deletions
@@ -1,5 +1,9 @@
import type { CSSProperties } from "react";
import { PageDraw as PageDrawLib } from "@storyteller/ui-pagedraw";
import { useEffect, type CSSProperties } from "react";
import {
PageDraw as PageDrawLib,
initDrawSessionSync,
teardownDrawSessionSync,
} from "@storyteller/ui-pagedraw";
import Seo from "../../components/seo";
import { useSidebar } from "../../components/ui/sidebar";
import { TopBarActions } from "../../components/topbar/TopBarActions";
@@ -7,6 +11,16 @@ import { useWebPageDrawAdapter } from "./web-adapter";
export default function PageDraw() {
const adapter = useWebPageDrawAdapter();
// Session persistence: local replica + server autosave for named sessions.
// The controller subscribes to the scene store itself — the page only owns
// its lifecycle. Teardown flushes pending local state.
useEffect(() => {
const persistence = adapter.persistence;
if (!persistence) return undefined;
initDrawSessionSync(persistence);
return () => teardownDrawSessionSync();
}, [adapter.persistence]);
// The global top bar is hidden on this route (desktop) so the editor
// reclaims the full vertical strip — mirror Edit 3D / Video Editor. On
// mobile the global bar stays, so don't re-host the actions here.
@@ -17,13 +17,16 @@
import { useMemo } from "react";
import {
type PageDrawAdapter,
type PageDrawPersistenceAdapter,
type ImageBundle,
type BaseSelectorImage,
useSceneStore,
} from "@storyteller/ui-pagedraw";
import { CommonAspectRatio, CommonResolution } from "@storyteller/model-list";
import {
MediaFilesApi,
OmniGenApi,
ProjectsApi,
StorytellerApiHostStore,
UploadImageMedia,
type OmniGenImageRequest,
@@ -31,6 +34,7 @@ import {
import { UploaderStates } from "@storyteller/common";
import { showToast } from "../../components/toast/toast";
import { useInsufficientCredits } from "../../components/insufficient-credits-modal";
import { useSessionStore } from "../../lib/session";
import { BaseImageSelector } from "./base-image-selector";
import { startPolling, type PolledImage } from "./job-polling";
@@ -184,6 +188,160 @@ const httpStatusFromError = (e: unknown): number | undefined => {
return match ? Number(match[1]) : undefined;
};
// ─── Session persistence (editor_2d project documents) ─────────────────────
// Server persistence over the project-document endpoints: each pagedraw
// session is one editor_2d project (multipart JSON upload; save-new returns
// the media file token linking the session to its server row). Mirrors the
// moodboard's webapp persistence adapter. All methods are non-throwing per
// the adapter contract.
export const pageDrawPersistence: PageDrawPersistenceAdapter = {
getUserId: () => useSessionStore.getState().user?.user_token ?? null,
subscribeAuthState: (onChange) => useSessionStore.subscribe(onChange),
uploadMedia: async (file) => {
let token: string | null = null;
try {
await UploadImageMedia({
title: file.name || "PageDraw image",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
token = String(state.data);
}
},
});
} catch (error) {
console.error("[PageDraw] media upload failed", error);
}
return token;
},
createProject: async ({ documentJson, name }) => {
try {
const response = await new ProjectsApi().UploadNewProject({
projectType: "editor_2d",
blob: new Blob([documentJson], { type: "application/json" }),
fileName: `${name || "Untitled session"}.editor.json`,
uuid: crypto.randomUUID(),
maybe_title: name,
});
return {
success: response.success && !!response.data,
token: response.data,
errorMessage: response.errorMessage,
};
} catch (error) {
console.error("[PageDraw] project create failed", error);
return { success: false };
}
},
updateProject: async ({ token, documentJson, name }) => {
try {
const response = await new ProjectsApi().UpdateProject({
projectType: "editor_2d",
token,
blob: new Blob([documentJson], { type: "application/json" }),
fileName: `${name || "Untitled session"}.editor.json`,
uuid: crypto.randomUUID(),
maybe_title: name,
});
return { success: response.success, errorMessage: response.errorMessage };
} catch (error) {
console.error("[PageDraw] project update failed", error);
return { success: false };
}
},
getProjectInfo: async (token) => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
if (!response.success || !response.data) return { success: false };
return {
success: true,
updatedAt: response.data.updated_at,
title: response.data.maybe_title ?? null,
};
} catch (error) {
console.error("[PageDraw] project info fetch failed", error);
return { success: false };
}
},
loadProjectDocument: async (token) => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const cdnUrl = response.success
? response.data?.media_links?.cdn_url
: undefined;
if (!cdnUrl) return { success: false };
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return { success: false };
return { success: true, documentJson: await documentResponse.text() };
} catch (error) {
console.error("[PageDraw] session document fetch failed", error);
return { success: false };
}
},
listProjects: async () => {
try {
const response = await new ProjectsApi().ListSessionProjects({
filter_project_type: "editor_2d",
page_size: 100,
});
if (!response.success || !response.data) return { success: false };
return {
success: true,
projects: response.data.map((row) => ({
token: row.token,
name: row.maybe_title ?? "Untitled session",
updatedAt: row.updated_at,
})),
};
} catch (error) {
console.error("[PageDraw] session list failed", error);
return { success: false };
}
},
deleteProject: async (token) => {
try {
const response = await new MediaFilesApi().DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
return response.success;
} catch (error) {
console.error("[PageDraw] session delete failed", error);
return false;
}
},
resolveMediaUrls: async (tokens) => {
try {
const response = await new MediaFilesApi().ListMediaFilesByTokens({
mediaTokens: tokens,
});
const urlByToken: Record<string, string> = {};
for (const media of response.data ?? []) {
if (media.media_links?.cdn_url) {
urlByToken[media.token] = media.media_links.cdn_url;
}
}
return urlByToken;
} catch (error) {
console.error("[PageDraw] media URL resolution failed", error);
return {};
}
},
};
// ─── Adapter ───────────────────────────────────────────────────────────────────
export const useWebPageDrawAdapter = (): PageDrawAdapter => {
@@ -293,6 +451,8 @@ export const useWebPageDrawAdapter = (): PageDrawAdapter => {
showLoading={showLoading}
/>
),
persistence: pageDrawPersistence,
}),
[openInsufficientCredits],
);
+28 -1
View File
@@ -10,7 +10,34 @@ export { useSceneStore, generateId } from "./lib/stores/SceneState";
export type { SceneState, AspectRatioType, LineNode } from "./lib/stores/SceneState";
// Adapter interface
export type { PageDrawAdapter, PageDrawEditRequest, PageDrawInpaintRequest } from "./lib/adapter";
export type {
PageDrawAdapter,
PageDrawEditRequest,
PageDrawInpaintRequest,
PageDrawPersistenceAdapter,
} from "./lib/adapter";
// Session persistence (server autosave + local replica). UI reads
// useDrawSessionStore reactively and issues intents via the command
// functions — one-way flow, no direct store writes from components.
export {
initDrawSessionSync,
teardownDrawSessionSync,
saveSessionNow,
nameSession,
newSession,
openSession,
listSessions,
resolveSessionConflict,
} from "./lib/persistence/sessionSync";
export { useDrawSessionStore } from "./lib/persistence/sessionStore";
export type {
DrawSessionState,
DrawSaveStatus,
DrawHydrationState,
DrawSessionConflict,
} from "./lib/persistence/sessionStore";
export type { PageDrawDocument } from "./lib/persistence/documentSchema";
// Shared types
export type { BaseSelectorImage, ImageBundle, DragState } from "./lib/types";
@@ -16,6 +16,10 @@ export interface NodeProps {
imageUrl?: string;
imageFile?: File;
imageElement?: HTMLImageElement;
/** Durable server media token for this image (upload write-back). Lets the
* persisted document reference the image by token instead of inlining
* base64, and re-resolve a fresh CDN URL on load. */
mediaId?: string;
backgroundColor?: string;
rotation?: number;
scaleX?: number;
@@ -44,6 +48,7 @@ export class Node {
imageUrl?: string;
imageFile?: File;
imageElement?: HTMLImageElement;
mediaId?: string;
backgroundColor?: string;
rotation: number;
scaleX: number;
@@ -69,6 +74,7 @@ export class Node {
imageUrl,
imageFile,
imageElement,
mediaId,
backgroundColor,
rotation = 0,
scaleX = 1,
@@ -93,6 +99,7 @@ export class Node {
this.imageUrl = imageUrl;
this.imageFile = imageFile;
this.imageElement = imageElement;
this.mediaId = mediaId;
this.backgroundColor = backgroundColor;
this.rotation = rotation;
this.scaleX = scaleX;
@@ -31,6 +31,64 @@ export interface PageDrawInpaintRequest {
frontendSubscriberId?: string;
}
/**
* Platform seam for pagedraw session persistence (server project documents of
* type `editor_2d` on the web). All methods are non-throwing: failures come
* back as `success: false` / null so a flaky network can never tear down the
* sync controller. Mirrors the moodboard persistence adapter so the two can
* later share an extracted `@storyteller/autosave` core.
*/
export interface PageDrawPersistenceAdapter {
/** Current account id, or null when signed out. Must be cheap/synchronous. */
getUserId(): string | null;
/** Subscribe to login-state changes; returns an unsubscribe. */
subscribeAuthState(onChange: () => void): () => void;
/** Upload a local image file, returning its durable media token. */
uploadMedia(file: File): Promise<string | null>;
/** Create a new server project row. Returns the new project token. */
createProject(params: {
documentJson: string;
name: string;
}): Promise<{ success: boolean; token?: string; errorMessage?: string }>;
/** Overwrite an existing project document (and its title). */
updateProject(params: {
token: string;
documentJson: string;
name: string;
}): Promise<{ success: boolean; errorMessage?: string }>;
/**
* Fetch a project's row metadata. `updatedAt` is the multi-device conflict
* base revision — it must reflect the latest server write.
*/
getProjectInfo(token: string): Promise<{
success: boolean;
updatedAt?: string;
title?: string | null;
}>;
/** Fetch the project's JSON document body. */
loadProjectDocument(token: string): Promise<{
success: boolean;
documentJson?: string;
}>;
/** List the user's pagedraw projects, newest first. */
listProjects(): Promise<{
success: boolean;
projects?: { token: string; name: string; updatedAt: string }[];
}>;
/** Delete a project row. */
deleteProject(token: string): Promise<boolean>;
/** Resolve media tokens to displayable URLs (missing tokens omitted). */
resolveMediaUrls(tokens: string[]): Promise<Record<string, string>>;
}
export interface PageDrawAdapter {
/** Platform-specific edit-image enqueue (Tauri invoke or REST POST). */
enqueueEditImage(req: PageDrawEditRequest): Promise<{ status: string }>;
@@ -57,4 +115,11 @@ export interface PageDrawAdapter {
modelType: string;
timestamp: number;
}) => void;
/**
* Optional server persistence for sessions. When absent (e.g. the Tauri
* host today), the sync controller is never initialized and pagedraw
* behaves exactly as before.
*/
persistence?: PageDrawPersistenceAdapter;
}
@@ -0,0 +1,263 @@
import { describe, expect, it } from "vitest";
import { Node } from "../Node";
import { AspectRatioType, type LineNode } from "../stores/SceneState";
import {
applyResolvedMediaUrls,
buildServerDocument,
collectDocumentMediaIds,
parseDocument,
PAGEDRAW_DOCUMENT_VERSION,
UNRESOLVED_MEDIA_DATA_URL,
type DocumentShapeNode,
type PageDrawDocument,
} from "./documentSchema";
import { decideServerHydration } from "./sessionSync";
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
describe("pagedraw document serialization", () => {
it("serializes shapes, lines, settings, and name into a 3.0 document", async () => {
const json = await buildServerDocument(buildScene(), "My session");
const doc = JSON.parse(json) as PageDrawDocument;
expect(doc.version).toBe(PAGEDRAW_DOCUMENT_VERSION);
expect(doc.name).toBe("My session");
expect(doc.brushColor).toBe("#ff0000");
expect(doc.brushSize).toBe(12);
expect(doc.fillColor).toBe("teal");
expect(doc.aspectRatioType).toBe(AspectRatioType.SQUARE);
expect(doc.drawNodes).toHaveLength(4);
expect(doc.inpaintLineNodes).toHaveLength(1);
const rect = doc.drawNodes[0] as DocumentShapeNode;
expect(rect.type).toBe("rectangle");
expect(rect.fill).toBe("#123456");
const line = doc.drawNodes[1] as LineNode;
expect(line.type).toBe("line");
expect(line.points).toEqual([0, 0, 10, 10]);
});
it("persists tokened images token-only and untokened ones as inline base64", async () => {
const json = await buildServerDocument(buildScene(), "My session");
const doc = JSON.parse(json) as PageDrawDocument;
// Tokened image: mediaId kept, no base64 payload.
const tokened = doc.drawNodes[2] as DocumentShapeNode;
expect(tokened.mediaId).toBe("media_token_abc");
expect(tokened.imageDataUrl).toBeUndefined();
// Untokened local image: inline base64 fallback so the upload gap can't
// lose the image.
const untokened = doc.drawNodes[3] as DocumentShapeNode;
expect(untokened.mediaId).toBeUndefined();
expect(untokened.imageDataUrl).toMatch(/^data:image\/png;base64,/);
});
it("never persists the unresolved-media placeholder URL", async () => {
const scene = buildScene();
const placeholderNode = new Node({
id: "img-placeholder",
x: 0,
y: 0,
width: 64,
height: 64,
fill: "",
type: "image",
mediaId: "media_token_missing",
imageUrl: UNRESOLVED_MEDIA_DATA_URL,
});
scene.drawNodes.push(placeholderNode);
const doc = JSON.parse(
await buildServerDocument(scene, "x"),
) as PageDrawDocument;
const persisted = doc.drawNodes[4] as DocumentShapeNode;
expect(persisted.mediaId).toBe("media_token_missing");
expect(persisted.imageUrl).toBeUndefined();
});
it("round-trips a 3.0 document through parseDocument", async () => {
const json = await buildServerDocument(buildScene(), "Round trip");
const doc = parseDocument(json);
expect(doc).not.toBeNull();
expect(doc?.version).toBe("3.0");
expect(doc?.name).toBe("Round trip");
expect(doc?.drawNodes).toHaveLength(4);
expect(doc?.brushSize).toBe(12);
});
it("accepts legacy 2.0 documents (no name, no mediaId)", () => {
const legacy = JSON.stringify({
drawNodes: [
{
id: "n1",
x: 1,
y: 2,
width: 10,
height: 10,
fill: "red",
type: "rectangle",
stroke: "#555",
strokeWidth: 2,
draggable: true,
rotation: 0,
scaleX: 1,
scaleY: 1,
offsetX: 0,
offsetY: 0,
locked: false,
},
],
inpaintLineNodes: [],
brushColor: "#00ff00",
brushSize: 7,
fillColor: "white",
aspectRatioType: "2:3",
version: "2.0",
});
const doc = parseDocument(legacy);
expect(doc).not.toBeNull();
expect(doc?.version).toBe("2.0");
expect(doc?.name).toBeUndefined();
expect(doc?.aspectRatioType).toBe(AspectRatioType.PORTRAIT);
expect((doc?.drawNodes[0] as DocumentShapeNode).fill).toBe("red");
});
it("rejects malformed documents instead of returning an empty canvas", () => {
expect(parseDocument("not json at all")).toBeNull();
expect(parseDocument("42")).toBeNull();
expect(parseDocument("{}")).toBeNull();
expect(parseDocument(JSON.stringify({ foo: "bar" }))).toBeNull();
});
it("collects unresolved media tokens and applies resolved URLs", async () => {
const json = await buildServerDocument(buildScene(), "x");
const doc = parseDocument(json);
expect(doc).not.toBeNull();
if (!doc) return;
// Only the tokened image needs resolution (the untokened one is inline).
expect(collectDocumentMediaIds(doc)).toEqual(["media_token_abc"]);
const resolved = applyResolvedMediaUrls(doc, {
media_token_abc: "https://cdn.example/img.png",
});
const node = resolved.drawNodes[2] as DocumentShapeNode;
expect(node.imageUrl).toBe("https://cdn.example/img.png");
});
it("falls back to the placeholder for unresolvable tokens", async () => {
const json = await buildServerDocument(buildScene(), "x");
const doc = parseDocument(json);
if (!doc) throw new Error("parse failed");
const resolved = applyResolvedMediaUrls(doc, {});
const node = resolved.drawNodes[2] as DocumentShapeNode;
expect(node.imageUrl).toBe(UNRESOLVED_MEDIA_DATA_URL);
});
});
describe("server hydration decision", () => {
it("uses the server copy when local is clean", () => {
expect(
decideServerHydration({
localDirty: false,
localBaseRevision: "2026-07-27T10:00:00Z",
serverUpdatedAt: "2026-07-28T09:00:00Z",
}),
).toBe("useServer");
});
it("pushes local edits when the server hasn't moved since our base", () => {
expect(
decideServerHydration({
localDirty: true,
localBaseRevision: "2026-07-27T10:00:00Z",
serverUpdatedAt: "2026-07-27T10:00:00Z",
}),
).toBe("useLocalAndPush");
});
it("flags a conflict when both sides have new work", () => {
expect(
decideServerHydration({
localDirty: true,
localBaseRevision: "2026-07-27T10:00:00Z",
serverUpdatedAt: "2026-07-28T09:00:00Z",
}),
).toBe("conflict");
});
it("flags a conflict when dirty local never learned a base revision", () => {
expect(
decideServerHydration({
localDirty: true,
localBaseRevision: null,
serverUpdatedAt: "2026-07-28T09:00:00Z",
}),
).toBe("conflict");
});
});
// ---------- helpers ----------
const buildScene = () => ({
drawNodes: [
new Node({
id: "rect-1",
x: 5,
y: 6,
width: 100,
height: 50,
fill: "#123456",
type: "rectangle" as const,
draggable: true,
}),
{
id: "line-1",
type: "line",
points: [0, 0, 10, 10],
stroke: "#000000",
strokeWidth: 3,
draggable: false,
} as LineNode,
new Node({
id: "img-tokened",
x: 0,
y: 0,
width: 64,
height: 64,
fill: "",
type: "image" as const,
mediaId: "media_token_abc",
imageFile: new File([PNG_BYTES], "a.png", { type: "image/png" }),
}),
new Node({
id: "img-untokened",
x: 10,
y: 10,
width: 64,
height: 64,
fill: "",
type: "image" as const,
imageFile: new File([PNG_BYTES], "b.png", { type: "image/png" }),
}),
],
inpaintLineNodes: [
{
id: "line-inpaint-1",
type: "line",
points: [1, 1, 2, 2],
stroke: "#ffffff",
strokeWidth: 20,
draggable: false,
} as LineNode,
],
brushColor: "#ff0000",
brushSize: 12,
fillColor: "teal",
aspectRatioType: AspectRatioType.SQUARE,
});
@@ -0,0 +1,226 @@
import { Node } from "../Node";
import type { Model3DParams } from "../utilities/render3DModel";
import type { LineNode, SceneState } from "../stores/SceneState";
import { AspectRatioType } from "../stores/SceneState";
// The pagedraw project document ("editor_2d" on the server). Version history:
// "2.0" — file import/export era: local images inlined as base64
// `imageDataUrl`, no media tokens, no name.
// "3.0" — adds `name` and per-node `mediaId` (durable server media token).
// Server documents reference images token-first and only fall back
// to inline base64 for images whose upload hasn't landed yet, so a
// flaky upload can never lose the user's image. File exports remain
// fully self-contained (base64 kept). Loaders accept both versions.
export const PAGEDRAW_DOCUMENT_VERSION = "3.0";
// Rendered when a node's mediaId can't be resolved to a URL at load time
// (deleted media, offline, transient API failure): a neutral "unavailable"
// tile. Never persisted — the serializer drops it so the document keeps the
// token and the next successful load self-heals to the real image.
export const UNRESOLVED_MEDIA_DATA_URL =
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
`<rect width="256" height="256" fill="#2a2a31"/>` +
`<path d="M88 168l32-40 24 28 16-20 28 32z" fill="#4a4a55"/>` +
`<circle cx="96" cy="96" r="14" fill="#4a4a55"/>` +
`</svg>`,
);
/** Shape node as persisted in a project document (no File / DOM handles). */
export interface DocumentShapeNode {
id: string;
x: number;
y: number;
width: number;
height: number;
fill: string;
type: "rectangle" | "circle" | "triangle" | "image";
stroke: string;
strokeWidth: number;
draggable: boolean;
imageUrl?: string;
/** Durable server media token; the loader re-resolves it to a CDN URL. */
mediaId?: string;
/** Inline base64 fallback, present only when the image has no token yet. */
imageDataUrl?: string;
backgroundColor?: string;
rotation: number;
scaleX: number;
scaleY: number;
offsetX: number;
offsetY: number;
locked: boolean;
modelUrl?: string;
model3dParams?: Model3DParams;
}
export type DocumentDrawNode = DocumentShapeNode | LineNode;
export interface PageDrawDocument {
version: string;
/** Session name — new in 3.0; absent in 2.0-era documents. */
name?: string;
drawNodes: DocumentDrawNode[];
inpaintLineNodes: LineNode[];
brushColor: string;
brushSize: number;
fillColor: string;
aspectRatioType: AspectRatioType;
}
/** The slice of SceneState the document is built from. */
export type SerializableSceneSlice = Pick<
SceneState,
| "drawNodes"
| "inpaintLineNodes"
| "brushColor"
| "brushSize"
| "fillColor"
| "aspectRatioType"
>;
/**
* Build the lean server document: token-first images (base64 stripped when a
* mediaId exists), inline base64 only as the no-token-yet fallback. The
* unresolved-media placeholder URL is never written back.
*/
export const buildServerDocument = async (
scene: SerializableSceneSlice,
name: string,
): Promise<string> => {
const drawNodes: DocumentDrawNode[] = await Promise.all(
scene.drawNodes.map(async (n) => {
if (!(n instanceof Node)) {
return JSON.parse(JSON.stringify(n)) as LineNode;
}
const shape: DocumentShapeNode = {
id: n.id,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
fill: n.fill,
type: n.type,
stroke: n.stroke,
strokeWidth: n.strokeWidth,
draggable: n.draggable,
imageUrl:
n.imageUrl === UNRESOLVED_MEDIA_DATA_URL ? undefined : n.imageUrl,
mediaId: n.mediaId,
backgroundColor: n.backgroundColor,
rotation: n.rotation || 0,
scaleX: n.scaleX || 1,
scaleY: n.scaleY || 1,
offsetX: n.offsetX || 0,
offsetY: n.offsetY || 0,
locked: n.locked || false,
modelUrl: n.modelUrl,
model3dParams: n.model3dParams,
};
if (!n.mediaId && n.imageFile instanceof File) {
try {
shape.imageDataUrl = await fileToDataUrl(n.imageFile);
} catch (error) {
console.error("[PageDraw] base64 fallback failed for node", n.id, error);
}
}
return shape;
}),
);
const document: PageDrawDocument = {
version: PAGEDRAW_DOCUMENT_VERSION,
name,
drawNodes,
inpaintLineNodes: JSON.parse(JSON.stringify(scene.inpaintLineNodes)),
brushColor: scene.brushColor,
brushSize: scene.brushSize,
fillColor: scene.fillColor,
aspectRatioType: scene.aspectRatioType,
};
return JSON.stringify(document);
};
/**
* Parse a persisted document, accepting both the 2.0 (base64-era) and 3.0
* shapes. Returns null for malformed input — callers treat that as a failed
* load, never as an empty document.
*/
export const parseDocument = (json: string): PageDrawDocument | null => {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
return null;
}
if (typeof raw !== "object" || raw === null) return null;
const data = raw as Record<string, unknown>;
if (!Array.isArray(data["drawNodes"]) && !Array.isArray(data["inpaintLineNodes"])) {
return null;
}
return {
version: typeof data["version"] === "string" ? data["version"] : "2.0",
name: typeof data["name"] === "string" ? data["name"] : undefined,
drawNodes: Array.isArray(data["drawNodes"])
? (data["drawNodes"] as DocumentDrawNode[])
: [],
inpaintLineNodes: Array.isArray(data["inpaintLineNodes"])
? (data["inpaintLineNodes"] as LineNode[])
: [],
brushColor:
typeof data["brushColor"] === "string" ? data["brushColor"] : "#000000",
brushSize: typeof data["brushSize"] === "number" ? data["brushSize"] : 5,
fillColor:
typeof data["fillColor"] === "string" ? data["fillColor"] : "white",
aspectRatioType: isAspectRatioType(data["aspectRatioType"])
? data["aspectRatioType"]
: AspectRatioType.NONE,
};
};
/** Collect the media tokens a document needs resolved before hydration. */
export const collectDocumentMediaIds = (doc: PageDrawDocument): string[] => {
const tokens = new Set<string>();
for (const node of doc.drawNodes) {
if (node.type === "line") continue;
const shape = node as DocumentShapeNode;
if (shape.mediaId && !shape.imageDataUrl) tokens.add(shape.mediaId);
}
return Array.from(tokens);
};
/**
* Apply resolved token→URL mappings onto a parsed document so the standard
* import path can load images. Unresolvable tokens get the placeholder tile
* (kept out of future saves by the serializer).
*/
export const applyResolvedMediaUrls = (
doc: PageDrawDocument,
urlByToken: Record<string, string>,
): PageDrawDocument => ({
...doc,
drawNodes: doc.drawNodes.map((node) => {
if (node.type === "line") return node;
const shape = node as DocumentShapeNode;
if (!shape.mediaId || shape.imageDataUrl) return shape;
const resolved = urlByToken[shape.mediaId];
if (resolved) return { ...shape, imageUrl: resolved };
if (shape.imageUrl) return shape;
return { ...shape, imageUrl: UNRESOLVED_MEDIA_DATA_URL };
}),
});
// ---------- helpers ----------
const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const isAspectRatioType = (value: unknown): value is AspectRatioType =>
typeof value === "string" &&
(Object.values(AspectRatioType) as string[]).includes(value);
@@ -0,0 +1,225 @@
import type {
HistoryNodeData,
LineNode,
SerializedDrawNode,
} from "../stores/SceneState";
import { AspectRatioType } from "../stores/SceneState";
// Durable local replica of pagedraw sessions, one record per session. This is
// the crash/reload safety net AND the home of the undo history (history is
// local-durable by design — it does not travel to the server).
//
// IndexedDB rather than localStorage because records carry File blobs (local
// images that haven't been uploaded yet) and history snapshots that can far
// exceed the ~5MB localStorage quota. Files structured-clone into IDB
// natively — no base64 inflation. The tradeoff: IDB writes are async, so
// unlike the moodboard's synchronous pagehide flush, the last <1s of edits
// before a hard kill can be lost. Writes are debounced tightly and fired
// (not awaited) on pagehide to keep that window minimal.
const DB_NAME = "artcraft_pagedraw";
const DB_VERSION = 1;
const STORE_NAME = "sessions";
// History snapshots dominate record size; keep a bounded tail. 50 entries
// comfortably covers a working session's undo needs.
export const REPLICA_HISTORY_LIMIT = 50;
/** Key for the per-account unnamed scratch session. */
export const draftKey = (ownerId: string | null): string =>
`draft:${ownerId ?? "anon"}`;
export interface PageDrawSessionRecord {
/** Primary key: the server token for named sessions, else a draft key. */
key: string;
/**
* Account this session belongs to (null = signed-out work). Replicas are
* machine-shared, so the sync layer refuses to push records owned by a
* different account.
*/
ownerId: string | null;
name: string;
remoteToken: string | null;
/**
* Server `updated_at` this replica last hydrated from or pushed to — the
* base revision for multi-device conflict detection. Null before the first
* successful sync.
*/
baseRevision: string | null;
/** True when local edits haven't reached the server yet. */
needsSync: boolean;
/** Client wall-clock of the last replica write. */
savedAt: number;
drawNodes: SerializedDrawNode[];
inpaintLineNodes: LineNode[];
brushColor: string;
brushSize: number;
fillColor: string;
aspectRatioType: AspectRatioType;
/**
* Bounded undo-history tail (see REPLICA_HISTORY_LIMIT) plus the index into
* it. When the live history is truncated to fit, the index is clamped: an
* index of -1 in a truncated window means "at the window's start", not
* necessarily "empty canvas".
*/
history: HistoryNodeData[];
historyIndex: number;
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
const openDb = (): Promise<IDBDatabase | null> => {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (typeof indexedDB === "undefined") {
resolve(null);
return;
}
try {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "key" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.error("[PageDraw] replica DB open failed", request.error);
resolve(null);
};
request.onblocked = () => resolve(null);
} catch (error) {
console.error("[PageDraw] replica DB open threw", error);
resolve(null);
}
});
return dbPromise;
};
/** Read one session record; null when absent or on any storage failure. */
export const readSessionRecord = async (
key: string,
): Promise<PageDrawSessionRecord | null> => {
const db = await openDb();
if (!db) return null;
return new Promise((resolve) => {
try {
const request = db
.transaction(STORE_NAME, "readonly")
.objectStore(STORE_NAME)
.get(key);
request.onsuccess = () =>
resolve((request.result as PageDrawSessionRecord | undefined) ?? null);
request.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
};
/**
* Write a session record, truncating history to the bounded tail. Resolves
* false on failure — callers keep needsSync semantics in the record itself,
* so a failed write just means the previous replica generation survives.
*/
export const writeSessionRecord = async (
record: PageDrawSessionRecord,
): Promise<boolean> => {
const db = await openDb();
if (!db) return false;
const start = Math.max(0, record.history.length - REPLICA_HISTORY_LIMIT);
const bounded: PageDrawSessionRecord =
start === 0
? record
: {
...record,
history: record.history.slice(start),
historyIndex: Math.min(
Math.max(record.historyIndex - start, -1),
record.history.length - start - 1,
),
};
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(bounded);
tx.oncomplete = () => resolve(true);
tx.onerror = () => {
console.error("[PageDraw] replica write failed", tx.error);
resolve(false);
};
tx.onabort = () => resolve(false);
} catch (error) {
console.error("[PageDraw] replica write threw", error);
resolve(false);
}
});
};
export const deleteSessionRecord = async (key: string): Promise<boolean> => {
const db = await openDb();
if (!db) return false;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
tx.onabort = () => resolve(false);
} catch {
resolve(false);
}
});
};
/** All stored session records (for resume-pending-sync and future UI). */
export const listSessionRecords = async (): Promise<PageDrawSessionRecord[]> => {
const db = await openDb();
if (!db) return [];
return new Promise((resolve) => {
try {
const request = db
.transaction(STORE_NAME, "readonly")
.objectStore(STORE_NAME)
.getAll();
request.onsuccess = () =>
resolve((request.result as PageDrawSessionRecord[]) ?? []);
request.onerror = () => resolve([]);
} catch {
resolve([]);
}
});
};
// Which session the page should reopen on next visit. Tiny and synchronous,
// so it lives in localStorage rather than IDB.
const LAST_ACTIVE_STORAGE_KEY = "artcraft_pagedraw_last_session_v1";
export const readLastActiveKey = (ownerId: string | null): string | null => {
try {
const raw = localStorage.getItem(LAST_ACTIVE_STORAGE_KEY);
if (!raw) return null;
const map = JSON.parse(raw) as Record<string, string>;
return map[ownerId ?? "anon"] ?? null;
} catch {
return null;
}
};
export const writeLastActiveKey = (
ownerId: string | null,
key: string | null,
): void => {
try {
const raw = localStorage.getItem(LAST_ACTIVE_STORAGE_KEY);
const map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
if (key === null) delete map[ownerId ?? "anon"];
else map[ownerId ?? "anon"] = key;
localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, JSON.stringify(map));
} catch {
// Non-fatal: next visit starts fresh instead of resuming.
}
};
@@ -0,0 +1,67 @@
import { create } from "zustand";
// Reactive session/persistence state for pagedraw. One-way flow: only the
// sync controller (sessionSync.ts) writes here; UI components subscribe with
// selectors and issue intents through the controller's exported commands
// (saveSessionNow, nameSession, resolveSessionConflict, ...). UI never
// mutates this store directly.
export type DrawSaveStatus =
| "idle"
| "dirty"
| "saving"
| "saved"
| "error"
/**
* Another device/session advanced the server copy past the revision this
* session last synced with. Autosave is paused until the user resolves via
* resolveSessionConflict — no side is silently overwritten.
*/
| "conflict";
export type DrawHydrationState = "idle" | "hydrating" | "ready";
export interface DrawSessionConflict {
/** The server copy's updated_at that superseded our base revision. */
serverUpdatedAt: string;
}
export interface DrawSessionState {
/** User-facing session name; empty until the user names the session. */
sessionName: string;
/**
* True once the user has named the session (the trigger for server row
* creation — unnamed work is protected locally but never creates rows).
*/
isNamed: boolean;
/** Server project token; null until the first successful named save. */
remoteToken: string | null;
hydration: DrawHydrationState;
saveStatus: DrawSaveStatus;
/** Epoch ms of the last successful server save. */
lastSavedAt: number | null;
errorMessage: string | null;
conflict: DrawSessionConflict | null;
loggedIn: boolean;
}
const INITIAL_STATE: DrawSessionState = {
sessionName: "",
isNamed: false,
remoteToken: null,
hydration: "idle",
saveStatus: "idle",
lastSavedAt: null,
errorMessage: null,
conflict: null,
loggedIn: false,
};
export const useDrawSessionStore = create<DrawSessionState>(() => ({
...INITIAL_STATE,
}));
/** Controller-only: reset to the pristine state (init / account switch). */
export const resetDrawSessionStore = (): void => {
useDrawSessionStore.setState({ ...INITIAL_STATE });
};
@@ -0,0 +1,927 @@
import { Node } from "../Node";
import type { PageDrawPersistenceAdapter } from "../adapter";
import type {
LineNode,
SerializedDrawNode,
SerializedNodeData,
} from "../stores/SceneState";
import { serializeDrawNode, useSceneStore } from "../stores/SceneState";
import {
applyResolvedMediaUrls,
buildServerDocument,
collectDocumentMediaIds,
parseDocument,
} from "./documentSchema";
import {
draftKey,
deleteSessionRecord,
PageDrawSessionRecord,
readLastActiveKey,
readSessionRecord,
writeLastActiveKey,
writeSessionRecord,
} from "./localReplica";
import { resetDrawSessionStore, useDrawSessionStore } from "./sessionStore";
// Sync controller for pagedraw sessions. Design (mirrors the moodboard sync
// layer, single-document variant):
//
// SceneState mutations ──(store subscription)──▶ markDirty
// ├─ debounced local-replica write (always — draft protection)
// └─ debounced server autosave (only once the session is named + owned)
// upload pending media → serialize → freshness check → push
//
// Guarantees:
// - Saves are gated behind hydration; a half-loaded canvas can never be
// pushed over the server copy.
// - Freshness check before every push: if another device advanced the server
// copy past our base revision, we surface a conflict (reactive store state)
// and pause autosave — the user chooses which side wins; neither is
// silently discarded (the losing side is stashed in a replica backup slot).
// - Replicas are owner-stamped; a record owned by a different account is
// never hydrated into the canvas and never pushed (machine-shared storage).
// - Failed pushes retry with exponential backoff; the durable needsSync flag
// resumes interrupted pushes on the next visit.
const AUTOSAVE_DEBOUNCE_MS = 2000;
const REPLICA_DEBOUNCE_MS = 600;
const RETRY_BASE_MS = 5000;
const RETRY_MAX_MS = 60000;
const MEDIA_UPLOAD_CONCURRENCY = 3;
export type ConflictResolution = "keepMine" | "takeServer";
interface ControllerState {
adapter: PageDrawPersistenceAdapter;
ownerId: string | null;
/** Replica key of the active session (project token or draft slot). */
sessionKey: string;
/** Server updated_at we last hydrated from / pushed to. */
baseRevision: string | null;
needsSync: boolean;
/** Edits arrived after the in-flight save serialized its snapshot. */
dirtySinceSnapshot: boolean;
saveInFlight: boolean;
trailingSave: boolean;
retryDelayMs: number;
saveTimer: ReturnType<typeof setTimeout> | null;
replicaTimer: ReturnType<typeof setTimeout> | null;
retryTimer: ReturnType<typeof setTimeout> | null;
unsubScene: () => void;
unsubAuth: () => void;
removeLifecycle: () => void;
}
let controller: ControllerState | null = null;
// Reentrant guard: controlled mutations (hydration, media-token write-backs,
// account-switch resets) must not re-mark the session dirty.
let suppressDepth = 0;
const withSuppressedDirty = <T>(fn: () => T): T => {
suppressDepth += 1;
try {
return fn();
} finally {
suppressDepth -= 1;
}
};
// ─── Lifecycle ─────────────────────────────────────────────────────────────
/**
* Initialize session persistence. Idempotent — re-initializing tears down the
* previous controller first. `openToken` forces a specific server session;
* otherwise the last-active session (or the account's draft) is restored.
*/
export const initDrawSessionSync = (
adapter: PageDrawPersistenceAdapter,
options: { openToken?: string } = {},
): void => {
teardownDrawSessionSync();
const ownerId = adapter.getUserId();
resetDrawSessionStore();
useDrawSessionStore.setState({
hydration: "hydrating",
loggedIn: ownerId !== null,
});
const unsubScene = useSceneStore.subscribe((state, prev) => {
if (suppressDepth > 0) return;
if (useDrawSessionStore.getState().hydration !== "ready") return;
const contentChanged =
state.history !== prev.history ||
state.historyIndex !== prev.historyIndex ||
state.brushColor !== prev.brushColor ||
state.brushSize !== prev.brushSize ||
state.fillColor !== prev.fillColor ||
state.aspectRatioType !== prev.aspectRatioType;
if (contentChanged) markDirty();
});
const unsubAuth = adapter.subscribeAuthState(() => {
const c = controller;
if (!c) return;
const nextOwner = c.adapter.getUserId();
if (nextOwner !== c.ownerId) void handleAccountSwitch(nextOwner);
});
// Best-effort flush when the tab hides/unloads. IDB puts are async so this
// is fire-and-forget, but the tight replica debounce keeps the at-risk
// window under a second either way.
const onPageHide = () => flushReplicaNow();
const onVisibility = () => {
if (document.visibilityState === "hidden") flushReplicaNow();
};
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibility);
controller = {
adapter,
ownerId,
sessionKey: draftKey(ownerId),
baseRevision: null,
needsSync: false,
dirtySinceSnapshot: false,
saveInFlight: false,
trailingSave: false,
retryDelayMs: RETRY_BASE_MS,
saveTimer: null,
replicaTimer: null,
retryTimer: null,
unsubScene,
unsubAuth,
removeLifecycle: () => {
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibility);
},
};
void hydrate(options.openToken);
};
/** Tear down the controller (page unmount). Flushes the replica if dirty. */
export const teardownDrawSessionSync = (): void => {
const c = controller;
if (!c) return;
if (c.saveTimer) clearTimeout(c.saveTimer);
if (c.replicaTimer) clearTimeout(c.replicaTimer);
if (c.retryTimer) clearTimeout(c.retryTimer);
c.unsubScene();
c.unsubAuth();
c.removeLifecycle();
if (c.needsSync) void writeReplica(c);
controller = null;
};
// ─── Public commands (UI intents) ──────────────────────────────────────────
/**
* Manual save. For a named session this is an immediate server push; for an
* unnamed one it flushes the local replica (server rows are only created via
* nameSession — naming is the user-triggered creation point).
*/
export const saveSessionNow = async (): Promise<void> => {
const c = controller;
if (!c) return;
const session = useDrawSessionStore.getState();
if (session.hydration !== "ready") return;
if (session.remoteToken) {
if (c.saveTimer) clearTimeout(c.saveTimer);
await doSave(c, { force: false });
} else if (session.isNamed && session.sessionName) {
await createServerSession(c, session.sessionName);
} else {
await flushReplicaNow();
}
};
/**
* Name (or rename) the session. The first naming of an unnamed session
* creates its server row and re-keys the local replica to the new token.
*/
export const nameSession = async (name: string): Promise<void> => {
const c = controller;
if (!c) return;
const trimmed = name.trim();
if (!trimmed) return;
const session = useDrawSessionStore.getState();
useDrawSessionStore.setState({ sessionName: trimmed, isNamed: true });
if (session.remoteToken) {
markDirty();
await saveSessionNow();
} else {
await createServerSession(c, trimmed);
}
};
/**
* Resolve a multi-device conflict. Either way the losing side is stashed in
* a replica backup slot first, so a mis-click is recoverable.
*/
export const resolveSessionConflict = async (
resolution: ConflictResolution,
): Promise<void> => {
const c = controller;
if (!c) return;
const session = useDrawSessionStore.getState();
const token = session.remoteToken;
if (session.saveStatus !== "conflict" || !token) return;
if (resolution === "keepMine") {
// Server copy is about to be overwritten — back it up locally first.
try {
const serverDoc = await c.adapter.loadProjectDocument(token);
if (serverDoc.success && serverDoc.documentJson) {
await backupDocumentJson(c, `backup:server:${token}`, serverDoc.documentJson);
}
} catch {
// Best-effort only.
}
useDrawSessionStore.setState({ conflict: null, saveStatus: "saving" });
await doSave(c, { force: true });
} else {
// Local copy is the losing side — snapshot it, then adopt the server's.
const backup = buildReplicaRecord(c);
if (backup) {
await writeSessionRecord({ ...backup, key: `backup:local:${token}` });
}
useDrawSessionStore.setState({ conflict: null });
c.needsSync = false;
await loadServerSession(c, token);
}
};
/** Start a fresh unnamed session (replaces the account's draft slot). */
export const newSession = async (): Promise<void> => {
const c = controller;
if (!c) return;
await flushReplicaNow();
c.sessionKey = draftKey(c.ownerId);
c.baseRevision = null;
c.needsSync = false;
resetSceneForSessionSwitch();
useDrawSessionStore.setState({
sessionName: "",
isNamed: false,
remoteToken: null,
saveStatus: "idle",
lastSavedAt: null,
errorMessage: null,
conflict: null,
hydration: "ready",
});
writeLastActiveKey(c.ownerId, c.sessionKey);
// The fresh empty canvas replaces whatever the draft slot held.
void writeReplica(c);
};
/** Open an existing server session by token. */
export const openSession = async (token: string): Promise<void> => {
const c = controller;
if (!c) return;
await flushReplicaNow();
useDrawSessionStore.setState({ hydration: "hydrating" });
await hydrate(token);
};
/** List the account's pagedraw sessions (for the picker UI). */
export const listSessions = async (): Promise<
{ token: string; name: string; updatedAt: string }[]
> => {
const c = controller;
if (!c) return [];
const response = await c.adapter.listProjects();
return response.success ? (response.projects ?? []) : [];
};
// ─── Hydration ─────────────────────────────────────────────────────────────
const hydrate = async (openToken?: string): Promise<void> => {
const c = controller;
if (!c) return;
let targetKey =
openToken ?? readLastActiveKey(c.ownerId) ?? draftKey(c.ownerId);
let record = await readSessionRecord(targetKey);
// Machine-shared storage: never hydrate another account's session. Null
// owner (signed-out work) is adoptable by whoever opens it.
if (record && record.ownerId !== null && record.ownerId !== c.ownerId) {
record = null;
if (!openToken) {
targetKey = draftKey(c.ownerId);
record = await readSessionRecord(targetKey);
if (record && record.ownerId !== null && record.ownerId !== c.ownerId) {
record = null;
}
}
}
c.sessionKey = targetKey;
writeLastActiveKey(c.ownerId, targetKey);
const isServerSession = !targetKey.startsWith("draft:") && !targetKey.startsWith("backup:");
if (isServerSession) {
await hydrateServerSession(c, targetKey, record);
} else if (record) {
await restoreReplicaRecord(c, record);
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: record.needsSync ? "dirty" : "idle",
});
} else {
useDrawSessionStore.setState({ hydration: "ready", saveStatus: "idle" });
}
};
/**
* Hydration for a token-linked session: dirty-aware last-write-wins with
* conflict detection, per the decision table in decideServerHydration.
*/
const hydrateServerSession = async (
c: ControllerState,
token: string,
record: PageDrawSessionRecord | null,
): Promise<void> => {
const info = await c.adapter.getProjectInfo(token);
if (!info.success) {
// Offline / transient failure: the replica (if any) keeps the user
// working; the durable needsSync flag resumes the push later.
if (record) {
await restoreReplicaRecord(c, record);
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: record.needsSync ? "error" : "idle",
errorMessage: "Couldn't reach the server — working from the local copy.",
});
if (record.needsSync) scheduleRetry(c);
} else {
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: "error",
errorMessage: "Couldn't load this session from the server.",
});
}
return;
}
const serverUpdatedAt = info.updatedAt ?? null;
const decision = decideServerHydration({
localDirty: record?.needsSync ?? false,
localBaseRevision: record?.baseRevision ?? null,
serverUpdatedAt,
});
if (decision === "useLocalAndPush" && record) {
await restoreReplicaRecord(c, record);
useDrawSessionStore.setState({ hydration: "ready", saveStatus: "dirty" });
scheduleAutosave(c);
return;
}
if (decision === "conflict" && record) {
// Show the user their local copy and pause sync until they choose.
await restoreReplicaRecord(c, record);
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: "conflict",
conflict: { serverUpdatedAt: serverUpdatedAt ?? "" },
});
return;
}
// Clean local (or no local): the server copy is the truth.
c.baseRevision = serverUpdatedAt;
await loadServerSession(c, token, info.title ?? undefined);
};
/**
* Pure hydration decision for a token-linked session — kept side-effect-free
* for direct unit testing.
*/
export const decideServerHydration = ({
localDirty,
localBaseRevision,
serverUpdatedAt,
}: {
localDirty: boolean;
localBaseRevision: string | null;
serverUpdatedAt: string | null;
}): "useLocalAndPush" | "conflict" | "useServer" => {
if (!localDirty) return "useServer";
// Dirty local, server unchanged since we last synced → our edits are the
// only new work; push them.
if (localBaseRevision !== null && serverUpdatedAt === localBaseRevision) {
return "useLocalAndPush";
}
// Dirty local AND the server moved (or we never learned its revision):
// both sides have work the other lacks.
return "conflict";
};
const loadServerSession = async (
c: ControllerState,
token: string,
serverTitle?: string,
): Promise<void> => {
const response = await c.adapter.loadProjectDocument(token);
if (!response.success || !response.documentJson) {
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: "error",
errorMessage: "Couldn't download this session's document.",
});
return;
}
const doc = parseDocument(response.documentJson);
if (!doc) {
// A malformed document must never be treated as an empty canvas — that
// path ends with autosave overwriting the server copy with nothing.
useDrawSessionStore.setState({
hydration: "ready",
saveStatus: "error",
errorMessage: "This session's document couldn't be parsed.",
});
return;
}
const urlByToken = await resolveMediaSafe(c, collectDocumentMediaIds(doc));
const resolved = applyResolvedMediaUrls(doc, urlByToken);
withSuppressedDirty(() => {
useSceneStore.getState().RESET();
useSceneStore.getState().importSceneFromJson(JSON.stringify(resolved));
});
const name = doc.name ?? serverTitle ?? "";
c.sessionKey = token;
c.needsSync = false;
useDrawSessionStore.setState({
sessionName: name,
isNamed: true,
remoteToken: token,
hydration: "ready",
saveStatus: "idle",
errorMessage: null,
conflict: null,
});
writeLastActiveKey(c.ownerId, token);
// Refresh the replica so a reload lands on this same content offline.
void writeReplica(c);
};
const restoreReplicaRecord = async (
c: ControllerState,
record: PageDrawSessionRecord,
): Promise<void> => {
const drawNodes = await deserializeDrawNodes(record.drawNodes);
withSuppressedDirty(() => {
useSceneStore.getState().RESET();
useSceneStore.setState({
drawNodes,
inpaintLineNodes: record.inpaintLineNodes,
brushColor: record.brushColor,
brushSize: record.brushSize,
fillColor: record.fillColor,
aspectRatioType: record.aspectRatioType,
history: record.history,
historyIndex: record.historyIndex,
selectedNodeIds: [],
});
});
c.baseRevision = record.baseRevision;
c.needsSync = record.needsSync;
useDrawSessionStore.setState({
sessionName: record.name,
isNamed: record.remoteToken !== null || record.name !== "",
remoteToken: record.remoteToken,
});
};
// ─── Dirty tracking + scheduling ───────────────────────────────────────────
const markDirty = (): void => {
const c = controller;
if (!c) return;
c.needsSync = true;
c.dirtySinceSnapshot = true;
const session = useDrawSessionStore.getState();
if (session.saveStatus !== "conflict" && session.saveStatus !== "saving") {
useDrawSessionStore.setState({ saveStatus: "dirty" });
}
if (c.replicaTimer) clearTimeout(c.replicaTimer);
c.replicaTimer = setTimeout(() => {
c.replicaTimer = null;
void writeReplica(c);
}, REPLICA_DEBOUNCE_MS);
// Server autosave only for named (row-backed) sessions, and never while a
// conflict is pending resolution.
if (session.remoteToken && session.saveStatus !== "conflict") {
scheduleAutosave(c);
}
};
const scheduleAutosave = (c: ControllerState): void => {
if (c.saveTimer) clearTimeout(c.saveTimer);
c.saveTimer = setTimeout(() => {
c.saveTimer = null;
void doSave(c, { force: false });
}, AUTOSAVE_DEBOUNCE_MS);
};
const scheduleRetry = (c: ControllerState): void => {
if (c.retryTimer) clearTimeout(c.retryTimer);
const delay = c.retryDelayMs;
c.retryDelayMs = Math.min(c.retryDelayMs * 2, RETRY_MAX_MS);
c.retryTimer = setTimeout(() => {
c.retryTimer = null;
void doSave(c, { force: false });
}, delay);
};
// ─── Save pipeline ─────────────────────────────────────────────────────────
const doSave = async (
c: ControllerState,
{ force }: { force: boolean },
): Promise<void> => {
if (controller !== c) return;
const session = useDrawSessionStore.getState();
if (session.hydration !== "ready") return;
if (!force && session.saveStatus === "conflict") return;
const token = session.remoteToken;
if (!token) return;
if (c.saveInFlight) {
c.trailingSave = true;
return;
}
c.saveInFlight = true;
c.dirtySinceSnapshot = false;
useDrawSessionStore.setState({ saveStatus: "saving", errorMessage: null });
try {
await uploadPendingMedia(c);
const documentJson = await buildServerDocument(
useSceneStore.getState(),
useDrawSessionStore.getState().sessionName,
);
// Freshness check immediately before the push (smallest race window).
if (!force) {
const info = await c.adapter.getProjectInfo(token);
if (!info.success) {
onSaveFailure(c, "Couldn't verify the server copy — will retry.");
return;
}
if (
c.baseRevision !== null &&
info.updatedAt !== undefined &&
info.updatedAt !== c.baseRevision
) {
useDrawSessionStore.setState({
saveStatus: "conflict",
conflict: { serverUpdatedAt: info.updatedAt },
});
return;
}
}
const response = await c.adapter.updateProject({
token,
documentJson,
name: useDrawSessionStore.getState().sessionName,
});
if (!response.success) {
onSaveFailure(c, response.errorMessage ?? "Save failed — will retry.");
return;
}
await adoptServerRevision(c, token);
onSaveSuccess(c);
} catch (error) {
console.error("[PageDraw] save failed", error);
onSaveFailure(c, "Save failed — will retry.");
} finally {
c.saveInFlight = false;
if (c.trailingSave && controller === c) {
c.trailingSave = false;
scheduleAutosave(c);
}
}
};
const createServerSession = async (
c: ControllerState,
name: string,
): Promise<void> => {
if (c.saveInFlight) {
c.trailingSave = true;
return;
}
const previousKey = c.sessionKey;
c.saveInFlight = true;
c.dirtySinceSnapshot = false;
useDrawSessionStore.setState({ saveStatus: "saving", errorMessage: null });
try {
await uploadPendingMedia(c);
const documentJson = await buildServerDocument(
useSceneStore.getState(),
name,
);
const response = await c.adapter.createProject({ documentJson, name });
if (!response.success || !response.token) {
// No row was created; the named draft stays local until a retry (via
// manual save) succeeds. No auto-retry here — creation is user-paced
// to avoid any chance of duplicate rows.
useDrawSessionStore.setState({
saveStatus: "error",
errorMessage:
response.errorMessage ?? "Couldn't create the session on the server.",
});
return;
}
const token = response.token;
useDrawSessionStore.setState({ remoteToken: token });
c.sessionKey = token;
await adoptServerRevision(c, token);
onSaveSuccess(c);
writeLastActiveKey(c.ownerId, token);
// The draft slot's content now lives under the token key.
if (previousKey.startsWith("draft:")) {
await writeReplica(c);
void deleteSessionRecord(previousKey);
}
} catch (error) {
console.error("[PageDraw] session create failed", error);
useDrawSessionStore.setState({
saveStatus: "error",
errorMessage: "Couldn't create the session on the server.",
});
} finally {
c.saveInFlight = false;
if (c.trailingSave && controller === c) {
c.trailingSave = false;
const token = useDrawSessionStore.getState().remoteToken;
if (token) scheduleAutosave(c);
}
}
};
const onSaveSuccess = (c: ControllerState): void => {
c.retryDelayMs = RETRY_BASE_MS;
if (c.retryTimer) {
clearTimeout(c.retryTimer);
c.retryTimer = null;
}
if (c.dirtySinceSnapshot) {
// Edits landed while the push was in flight — the snapshot we saved is
// already stale, so stay dirty and go again.
useDrawSessionStore.setState({
saveStatus: "dirty",
lastSavedAt: Date.now(),
});
scheduleAutosave(c);
} else {
c.needsSync = false;
useDrawSessionStore.setState({
saveStatus: "saved",
lastSavedAt: Date.now(),
errorMessage: null,
});
}
void writeReplica(c);
};
const onSaveFailure = (c: ControllerState, message: string): void => {
useDrawSessionStore.setState({ saveStatus: "error", errorMessage: message });
scheduleRetry(c);
};
/**
* After a successful push, adopt the server's new updated_at as our base
* revision. If this refetch fails we leave the base null; the next save's
* freshness check then has nothing to compare against and pushes anyway —
* one save cycle of plain last-write-wins rather than a false conflict.
*/
const adoptServerRevision = async (
c: ControllerState,
token: string,
): Promise<void> => {
try {
const info = await c.adapter.getProjectInfo(token);
c.baseRevision = info.success ? (info.updatedAt ?? null) : null;
} catch {
c.baseRevision = null;
}
};
/** Upload local image files that don't have a media token yet. */
const uploadPendingMedia = async (c: ControllerState): Promise<void> => {
const pending = useSceneStore
.getState()
.drawNodes.filter(
(n): n is Node => n instanceof Node && !!n.imageFile && !n.mediaId,
);
if (pending.length === 0) return;
let index = 0;
const workers = Array.from(
{ length: Math.min(MEDIA_UPLOAD_CONCURRENCY, pending.length) },
async () => {
while (index < pending.length) {
const node = pending[index];
index += 1;
try {
const mediaToken = await c.adapter.uploadMedia(node.imageFile as File);
if (!mediaToken) continue;
// The node may have been deleted/replaced while uploading.
const live = useSceneStore
.getState()
.drawNodes.find((n) => n.id === node.id);
if (!(live instanceof Node) || live.imageFile !== node.imageFile) {
continue;
}
withSuppressedDirty(() => {
useSceneStore.getState().updateNode(node.id, { mediaId: mediaToken }, false);
});
} catch (error) {
// Non-fatal: the document keeps this image inline as base64 until
// a later save's upload succeeds.
console.error("[PageDraw] media upload failed", error);
}
}
},
);
await Promise.all(workers);
};
// ─── Replica plumbing ──────────────────────────────────────────────────────
const buildReplicaRecord = (
c: ControllerState,
): PageDrawSessionRecord | null => {
const session = useDrawSessionStore.getState();
if (session.hydration !== "ready") return null;
const scene = useSceneStore.getState();
return {
key: c.sessionKey,
ownerId: c.ownerId,
name: session.sessionName,
remoteToken: session.remoteToken,
baseRevision: c.baseRevision,
needsSync: c.needsSync,
savedAt: Date.now(),
drawNodes: scene.drawNodes.map(serializeDrawNode),
inpaintLineNodes: JSON.parse(JSON.stringify(scene.inpaintLineNodes)),
brushColor: scene.brushColor,
brushSize: scene.brushSize,
fillColor: scene.fillColor,
aspectRatioType: scene.aspectRatioType,
history: scene.history,
historyIndex: scene.historyIndex,
};
};
const writeReplica = async (c: ControllerState): Promise<void> => {
const record = buildReplicaRecord(c);
if (!record) return;
await writeSessionRecord(record);
};
const flushReplicaNow = (): void => {
const c = controller;
if (!c) return;
if (c.replicaTimer) {
clearTimeout(c.replicaTimer);
c.replicaTimer = null;
}
void writeReplica(c);
};
const backupDocumentJson = async (
c: ControllerState,
key: string,
documentJson: string,
): Promise<void> => {
const doc = parseDocument(documentJson);
if (!doc) return;
await writeSessionRecord({
key,
ownerId: c.ownerId,
name: doc.name ?? "",
remoteToken: null,
baseRevision: null,
needsSync: false,
savedAt: Date.now(),
drawNodes: doc.drawNodes as SerializedDrawNode[],
inpaintLineNodes: doc.inpaintLineNodes,
brushColor: doc.brushColor,
brushSize: doc.brushSize,
fillColor: doc.fillColor,
aspectRatioType: doc.aspectRatioType,
history: [],
historyIndex: -1,
});
};
// ─── Account switching ─────────────────────────────────────────────────────
/**
* The signed-in account changed under us. Flush the outgoing account's
* replica, clear the canvas (cross-account content must never linger), and
* hydrate the incoming account's last session. Signed-out drafts are
* adoptable: logging in stamps the anonymous draft with the new owner.
*/
const handleAccountSwitch = async (nextOwner: string | null): Promise<void> => {
const c = controller;
if (!c) return;
if (c.saveTimer) clearTimeout(c.saveTimer);
if (c.retryTimer) clearTimeout(c.retryTimer);
c.saveTimer = null;
c.retryTimer = null;
flushReplicaNow();
const previousOwner = c.ownerId;
c.ownerId = nextOwner;
c.baseRevision = null;
c.needsSync = false;
// anon → signed-in: adopt the anonymous draft into the new account rather
// than hiding the work the user just did before logging in.
if (previousOwner === null && nextOwner !== null) {
const anonRecord = await readSessionRecord(draftKey(null));
const ownRecord = await readSessionRecord(draftKey(nextOwner));
if (anonRecord && !ownRecord) {
await writeSessionRecord({
...anonRecord,
key: draftKey(nextOwner),
ownerId: nextOwner,
});
void deleteSessionRecord(draftKey(null));
writeLastActiveKey(nextOwner, draftKey(nextOwner));
}
}
resetSceneForSessionSwitch();
resetDrawSessionStore();
useDrawSessionStore.setState({
hydration: "hydrating",
loggedIn: nextOwner !== null,
});
await hydrate();
};
const resetSceneForSessionSwitch = (): void => {
withSuppressedDirty(() => {
useSceneStore.getState().RESET();
});
};
// ─── Deserialization ───────────────────────────────────────────────────────
/** Rebuild live nodes from replica-serialized form (async image loads). */
const deserializeDrawNodes = async (
nodes: SerializedDrawNode[],
): Promise<(Node | LineNode)[]> =>
Promise.all(
nodes.map(async (nodeData) => {
if (nodeData.type === "line") return nodeData as LineNode;
const shapeData = nodeData as SerializedNodeData;
const node = new Node(shapeData);
if (node.type === "image" && (node.imageUrl || node.imageFile)) {
try {
if (node.imageFile) {
await node.setImageFromFile(node.imageFile);
} else if (node.imageUrl) {
await node.setImageFromUrl(node.imageUrl);
}
} catch (error) {
console.error("[PageDraw] image restore failed", error);
}
}
return node;
}),
);
const resolveMediaSafe = async (
c: ControllerState,
tokens: string[],
): Promise<Record<string, string>> => {
if (tokens.length === 0) return {};
try {
return await c.adapter.resolveMediaUrls(tokens);
} catch (error) {
console.error("[PageDraw] media URL resolution failed", error);
return {};
}
};
@@ -69,8 +69,11 @@ export type ActiveTool =
| "backgroundColor"
| "shape";
// Serialized shape node for history storage
type SerializedNodeData = {
// Serialized shape node for history storage. Exported for the persistence
// layer, whose local replica stores history entries in this same shape
// (plain data + File blobs — both IndexedDB structured-cloneable; note the
// deliberate absence of the non-cloneable HTMLImageElement handle).
export type SerializedNodeData = {
id: string;
x: number;
y: number;
@@ -83,6 +86,7 @@ type SerializedNodeData = {
draggable: boolean;
imageUrl?: string;
imageFile?: File;
mediaId?: string;
backgroundColor?: string;
rotation: number;
scaleX: number;
@@ -95,13 +99,45 @@ type SerializedNodeData = {
};
// A history draw node is either a serialized shape or an inline LineNode
type SerializedDrawNode = SerializedNodeData | LineNode;
export type SerializedDrawNode = SerializedNodeData | LineNode;
interface HistoryNodeData {
export interface HistoryNodeData {
drawNodes: SerializedDrawNode[];
inpaintLineNodes: LineNode[];
}
/** Serialize one live draw node into its history/replica form. */
export const serializeDrawNode = (n: Node | LineNode): SerializedDrawNode => {
if (n instanceof Node) {
return {
id: n.id,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
fill: n.fill,
type: n.type,
stroke: n.stroke,
strokeWidth: n.strokeWidth,
draggable: n.draggable,
imageUrl: n.imageUrl,
imageFile: n.imageFile,
mediaId: n.mediaId,
backgroundColor: n.backgroundColor,
rotation: n.rotation || 0,
scaleX: n.scaleX || 1,
scaleY: n.scaleY || 1,
offsetX: n.offsetX || 0,
offsetY: n.offsetY || 0,
locked: n.locked || false,
modelUrl: n.modelUrl,
model3dParams: n.model3dParams,
};
}
// LineNode — plain object, deep copy
return JSON.parse(JSON.stringify(n)) as LineNode;
};
export interface SceneState {
/**
* All draw-layer items in render order (index 0 = bottom, last = top).
@@ -533,39 +569,8 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
if (isRestoring) return;
set((state) => {
const serializableState: {
drawNodes: SerializedDrawNode[];
inpaintLineNodes: LineNode[];
} = {
drawNodes: state.drawNodes.map((n) => {
if (n instanceof Node) {
return {
id: n.id,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
fill: n.fill,
type: n.type,
stroke: n.stroke,
strokeWidth: n.strokeWidth,
draggable: n.draggable,
imageUrl: n.imageUrl,
imageFile: n.imageFile,
backgroundColor: n.backgroundColor,
rotation: n.rotation || 0,
scaleX: n.scaleX || 1,
scaleY: n.scaleY || 1,
offsetX: n.offsetX || 0,
offsetY: n.offsetY || 0,
locked: n.locked || false,
modelUrl: n.modelUrl,
model3dParams: n.model3dParams,
} as SerializedNodeData;
}
// LineNode — plain object, deep copy
return JSON.parse(JSON.stringify(n)) as LineNode;
}),
const serializableState: HistoryNodeData = {
drawNodes: state.drawNodes.map(serializeDrawNode),
inpaintLineNodes: JSON.parse(JSON.stringify(state.inpaintLineNodes)),
};
@@ -1080,6 +1085,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
strokeWidth: node.strokeWidth,
draggable: node.draggable,
imageUrl: node.imageUrl,
mediaId: node.mediaId,
backgroundColor: node.backgroundColor,
rotation: node.rotation || 0,
scaleX: node.scaleX || 1,
@@ -1109,7 +1115,11 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
brushSize: state.brushSize,
fillColor: state.fillColor,
aspectRatioType: state.aspectRatioType,
version: "2.0",
// 3.0 = nodes may carry a durable `mediaId` media token alongside (or
// instead of) inline base64. File exports stay self-contained (base64
// kept); server persistence strips base64 when a token exists. Loaders
// accept 2.0 and 3.0.
version: "3.0",
};
return JSON.stringify(sceneData, null, 2);
@@ -1317,6 +1327,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const updatedNode = new Node({
...firstNode,
imageUrl: imageCdnUrl,
mediaId: mediaToken,
});
await updatedNode.setImageFromUrl(imageCdnUrl);
set((state) => ({
@@ -1391,34 +1402,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const currentBaseImage = get().baseImageInfo;
if (currentBaseImage) {
get().historyImageNodeMap.set(currentBaseImage, {
drawNodes: get().drawNodes.map((n) => {
if (n instanceof Node) {
return {
id: n.id,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
fill: n.fill,
type: n.type,
stroke: n.stroke,
strokeWidth: n.strokeWidth,
draggable: n.draggable,
imageUrl: n.imageUrl,
imageFile: n.imageFile,
backgroundColor: n.backgroundColor,
rotation: n.rotation || 0,
scaleX: n.scaleX || 1,
scaleY: n.scaleY || 1,
offsetX: n.offsetX || 0,
offsetY: n.offsetY || 0,
locked: n.locked || false,
modelUrl: n.modelUrl,
model3dParams: n.model3dParams,
} as SerializedNodeData;
}
return JSON.parse(JSON.stringify(n)) as LineNode;
}),
drawNodes: get().drawNodes.map(serializeDrawNode),
inpaintLineNodes: JSON.parse(JSON.stringify(get().inpaintLineNodes)),
});
}