mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user