diff --git a/README.md b/README.md index b5bec98..fc50c8f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Image Studio is a Bun + React image editor prototype. It uses a command-driven a ## Features - Import images into an artboard +- Save and open versioned project files with embedded, project-owned assets +- Automatic local recovery snapshots after document changes - Export the active artboard as PNG - Layer selection and layer sheet - Select, transform, pan, brush, eraser, chroma key, and magic wand tools @@ -71,6 +73,8 @@ bun start | `Space` | Hold to pan | | `L` | Toggle layers | | `Cmd/Ctrl` + `O` | Open image | +| `Shift` + `Cmd/Ctrl` + `O` | Open project | +| `Cmd/Ctrl` + `S` | Save project | | `Cmd/Ctrl` + `Z` | Undo | | `Shift` + `Cmd/Ctrl` + `Z` | Redo | | `Delete` / `Backspace` | Delete selection | diff --git a/app/app.ts b/app/app.ts index cf6e617..2d87064 100644 --- a/app/app.ts +++ b/app/app.ts @@ -10,13 +10,14 @@ import { transformCommands } from "@commands/transform"; import { viewportCommands } from "@commands/viewport"; import { workspaceCommands } from "@commands/workspace"; import { editorCommands } from "@commands/editor"; +import { projectCommands } from "@commands/project"; import { createInitialAppState } from "@editor/initial-state"; import { createAppStore } from "@editor/store"; export type ImageStudioApp = ReturnType; export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) { - const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]); + const registry = createCommandRegistry([...projectCommands, ...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands, ...workspaceCommands, ...editorCommands]); const store = createAppStore(createInitialAppState(options?.documentName), registry); if (options?.createDefaultArtboard !== false) { diff --git a/commands/ids.ts b/commands/ids.ts index 4cecdc9..8da84ea 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -1,4 +1,5 @@ export const commandIds = { + projectOpen: "project.open", documentAddArtboard: "document.addArtboard", documentSetArtboardBounds: "document.setArtboardBounds", documentRemoveArtboard: "document.removeArtboard", diff --git a/commands/index.ts b/commands/index.ts index 46311ab..f9e4f88 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -1,4 +1,6 @@ export type { Command, CommandContext } from "./command"; +export { projectCommands, projectOpenCommand } from "./project"; +export type { ProjectOpenPayload } from "./project"; export { documentAddArtboardCommand, documentAddAssetCommand, diff --git a/commands/payloads.ts b/commands/payloads.ts index 88b832f..c1e913d 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -46,6 +46,7 @@ import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPrevie import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { WorkspaceSetPanelPayload } from "./workspace"; import type { EditorSetPointerSessionPayload } from "./editor"; +import type { ProjectOpenPayload } from "./project"; import type { ViewportFitArtboardPayload, ViewportPanPayload, @@ -55,6 +56,7 @@ import type { } from "./viewport"; export type CommandPayloads = { + [commandIds.projectOpen]: ProjectOpenPayload; [commandIds.documentAddArtboard]: DocumentAddArtboardPayload; [commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload; [commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload; diff --git a/commands/project.test.ts b/commands/project.test.ts new file mode 100644 index 0000000..4ef84a2 --- /dev/null +++ b/commands/project.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { createInitialAppState } from "@editor/initial-state"; +import { createAppStore } from "@editor/store"; +import { createCommandRegistry } from "./registry"; +import { documentAddArtboardCommand, documentRenameArtboardCommand } from "./document"; +import { projectOpenCommand } from "./project"; +import { commandIds } from "./ids"; + +describe("open project command", () => { + test("replaces the document and resets transient sessions and history", () => { + const app = createTestApp("Original"); + const replacement = createTestApp("Opened").store.getState().document; + const originalArtboardId = app.store.getState().document.artboards[0]!.id; + app.store.dispatch(commandIds.documentRenameArtboard, { id: originalArtboardId, name: "Changed" }); + expect(app.store.getState().history.past).toHaveLength(2); + + app.store.dispatch(commandIds.projectOpen, { document: replacement }); + + const state = app.store.getState(); + expect(state.document).toBe(replacement); + expect(state.editor.selection).toEqual({ artboardId: replacement.artboards[0]!.id, layerIds: [] }); + expect(state.editor.generation.candidates).toEqual([]); + expect(state.history).toEqual({ past: [], future: [] }); + }); +}); + +function createTestApp(name: string) { + const store = createAppStore(createInitialAppState(name), createCommandRegistry([projectOpenCommand, documentAddArtboardCommand, documentRenameArtboardCommand])); + store.dispatch(commandIds.documentAddArtboard, { id: `${name}-artboard`, name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } }); + return { store }; +} diff --git a/commands/project.ts b/commands/project.ts new file mode 100644 index 0000000..5e2b4f2 --- /dev/null +++ b/commands/project.ts @@ -0,0 +1,37 @@ +import type { ImageDocument } from "@core/document"; +import { initialEditorState } from "@editor/initial-state"; +import type { Command } from "./command"; +import { commandIds } from "./ids"; + +export type ProjectOpenPayload = { document: ImageDocument }; + +export const projectOpenCommand: Command = { + id: commandIds.projectOpen, + name: "Open project", + history: { mode: "ignore" }, + execute({ state }, payload) { + const firstArtboard = payload.document.artboards[0]; + const viewport = state.editor.viewport; + const padding = 48; + const availableWidth = Math.max(0, viewport.size.w - padding * 2); + const availableHeight = Math.max(0, viewport.size.h - padding * 2); + const canFit = Boolean(firstArtboard && availableWidth > 0 && availableHeight > 0 && firstArtboard.bounds.w > 0 && firstArtboard.bounds.h > 0); + return { + document: payload.document, + editor: { + ...initialEditorState, + viewport: { + ...viewport, + center: firstArtboard + ? { x: firstArtboard.bounds.x + firstArtboard.bounds.w / 2, y: firstArtboard.bounds.y + firstArtboard.bounds.h / 2 } + : viewport.center, + zoom: canFit && firstArtboard ? Math.max(0.01, Math.min(availableWidth / firstArtboard.bounds.w, availableHeight / firstArtboard.bounds.h)) : viewport.zoom, + }, + selection: { artboardId: firstArtboard?.id, layerIds: [] }, + }, + history: { past: [], future: [] }, + }; + }, +}; + +export const projectCommands = [projectOpenCommand] satisfies Command[]; diff --git a/operations/project/format.test.ts b/operations/project/format.test.ts new file mode 100644 index 0000000..7291912 --- /dev/null +++ b/operations/project/format.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import type { ImageDocument } from "@core/document"; +import { CURRENT_PROJECT_VERSION, parseProject, projectFileName, serializeProject } from "./format"; + +describe("project format", () => { + test("round-trips a versioned project with embedded assets", () => { + const document = projectDocument(); + const result = parseProject(serializeProject(document, "2026-07-10T12:00:00.000Z")); + + expect(result.version).toBe(CURRENT_PROJECT_VERSION); + expect(result.savedAt).toBe("2026-07-10T12:00:00.000Z"); + expect(result.document).toEqual(document); + }); + + test("migrates a legacy bare document", () => { + const result = parseProject(JSON.stringify(projectDocument())); + expect(result.version).toBe(1); + expect(result.savedAt).toBe("1970-01-01T00:00:00.000Z"); + }); + + test("rejects unknown versions and invalid JSON", () => { + expect(() => parseProject("not json")).toThrow("not valid JSON"); + expect(() => parseProject(JSON.stringify({ format: "image-studio-project", version: 99, savedAt: "now", document: projectDocument() }))).toThrow("Unsupported project version"); + }); + + test("enforces project-owned embedded assets and valid references", () => { + const externalAsset = projectDocument(); + externalAsset.assets[0]!.source = "blob:https://example.test/asset"; + expect(() => serializeProject(externalAsset)).toThrow("not embedded"); + + const missingAsset = projectDocument(); + missingAsset.assets = []; + expect(() => serializeProject(missingAsset)).toThrow("references missing asset"); + }); + + test("creates safe project file names", () => { + expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json"); + expect(projectFileName("***")).toBe("untitled.image-studio.json"); + }); +}); + +function projectDocument(): ImageDocument { + return { + id: "document-1", + name: "Test Project", + version: 1, + assets: [{ id: "asset-1", name: "pixels.png", mimeType: "image/png", source: "data:image/png;base64,AA==", intrinsicSize: { w: 10, h: 20 } }], + artboards: [{ + id: "artboard-1", + name: "Board", + bounds: { x: 0, y: 0, w: 100, h: 100 }, + backgroundColor: "transparent", + visible: true, + locked: false, + layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }], + }], + }; +} diff --git a/operations/project/format.ts b/operations/project/format.ts new file mode 100644 index 0000000..de2ef00 --- /dev/null +++ b/operations/project/format.ts @@ -0,0 +1,150 @@ +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; + +export const PROJECT_FORMAT = "image-studio-project"; +export const CURRENT_PROJECT_VERSION = 1; + +export type ProjectFile = { + format: typeof PROJECT_FORMAT; + version: typeof CURRENT_PROJECT_VERSION; + savedAt: string; + document: ImageDocument; +}; + +export function serializeProject(document: ImageDocument, savedAt = new Date().toISOString()): string { + assertDocumentAssetOwnership(document); + return JSON.stringify({ format: PROJECT_FORMAT, version: CURRENT_PROJECT_VERSION, savedAt, document } satisfies ProjectFile); +} + +export function parseProject(source: string): ProjectFile { + let value: unknown; + try { + value = JSON.parse(source); + } catch { + throw new Error("The selected file is not valid JSON."); + } + + const migrated = migrateProject(value); + assertImageDocument(migrated.document); + assertDocumentAssetOwnership(migrated.document); + return migrated; +} + +export function projectFileName(documentName: string): string { + const base = documentName.trim().replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "") || "untitled"; + return `${base}.image-studio.json`; +} + +function migrateProject(value: unknown): ProjectFile { + if (!isRecord(value)) throw new Error("The project file must contain an object."); + + if (value.format === PROJECT_FORMAT) { + if (value.version !== CURRENT_PROJECT_VERSION) { + throw new Error(`Unsupported project version: ${String(value.version)}.`); + } + if (typeof value.savedAt !== "string") throw new Error("The project is missing its save timestamp."); + return value as ProjectFile; + } + + // Legacy exports stored ImageDocument directly. Loading upgrades them to v1. + if (looksLikeDocument(value)) { + return { + format: PROJECT_FORMAT, + version: CURRENT_PROJECT_VERSION, + savedAt: new Date(0).toISOString(), + document: value as ImageDocument, + }; + } + + throw new Error("This is not an Image Studio project file."); +} + +function assertImageDocument(value: unknown): asserts value is ImageDocument { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string" || typeof value.version !== "number") { + throw new Error("The project contains an invalid document."); + } + if (!Array.isArray(value.artboards) || !Array.isArray(value.assets)) throw new Error("The project document is incomplete."); + + for (const asset of value.assets) { + if (!isRecord(asset) || typeof asset.id !== "string" || typeof asset.name !== "string" || typeof asset.mimeType !== "string" || typeof asset.source !== "string" || !isSize(asset.intrinsicSize)) { + throw new Error("The project contains an invalid asset."); + } + } + for (const artboard of value.artboards) { + if (!isRecord(artboard) || typeof artboard.id !== "string" || typeof artboard.name !== "string" || !isRect(artboard.bounds) || !Array.isArray(artboard.layers)) { + throw new Error("The project contains an invalid artboard."); + } + assertLayers(artboard.layers); + } +} + +export function assertDocumentAssetOwnership(document: ImageDocument): void { + const assetIds = new Set(); + for (const asset of document.assets) { + if (assetIds.has(asset.id)) throw new Error(`Duplicate asset id: ${asset.id}.`); + assetIds.add(asset.id); + if (!isOwnedAssetSource(asset.source)) throw new Error(`Asset ${asset.name} is not embedded in the project.`); + } + + const layerIds = new Set(); + for (const artboard of document.artboards) { + walkLayers(artboard.layers, (layer) => { + if (layerIds.has(layer.id)) throw new Error(`Duplicate layer id: ${layer.id}.`); + layerIds.add(layer.id); + if ((layer.type === "image" || layer.type === "raster") && !assetIds.has(layer.assetId)) { + throw new Error(`Layer ${layer.name} references missing asset ${layer.assetId}.`); + } + }); + } +} + +function isOwnedAssetSource(source: string): boolean { + return source.startsWith("data:"); +} + +function assertLayers(value: unknown[]): asserts value is Layer[] { + for (const layer of value) { + if (!isRecord(layer) || typeof layer.id !== "string" || typeof layer.name !== "string" || typeof layer.type !== "string" || typeof layer.visible !== "boolean" || typeof layer.locked !== "boolean" || typeof layer.opacity !== "number" || !isTransform(layer.transform)) { + throw new Error("The project contains an invalid layer."); + } + if (layer.type === "group") { + if (!Array.isArray(layer.children)) throw new Error("A project group is missing its children."); + assertLayers(layer.children); + } else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") { + throw new Error("A project layer is missing its asset reference."); + } else if (layer.type !== "image" && layer.type !== "raster") { + throw new Error(`Unsupported layer type: ${layer.type}.`); + } + } +} + +function walkLayers(layers: Layer[], visit: (layer: Layer) => void): void { + for (const layer of layers) { + visit(layer); + if (layer.type === "group") walkLayers(layer.children, visit); + } +} + +function looksLikeDocument(value: Record): boolean { + return typeof value.id === "string" && Array.isArray(value.artboards) && Array.isArray(value.assets); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSize(value: unknown): boolean { + return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0; +} + +function isRect(value: unknown): boolean { + return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h); +} + +function isTransform(value: unknown): boolean { + return isRecord(value) && isRecord(value.position) && isFiniteNumber(value.position.x) && isFiniteNumber(value.position.y) && isRecord(value.scale) && isFiniteNumber(value.scale.x) && isFiniteNumber(value.scale.y) && isFiniteNumber(value.rotation); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/operations/project/lifecycle.test.ts b/operations/project/lifecycle.test.ts new file mode 100644 index 0000000..1c92d6f --- /dev/null +++ b/operations/project/lifecycle.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import { documentAddArtboardCommand, documentRenameArtboardCommand } from "@commands/document"; +import { projectOpenCommand } from "@commands/project"; +import { createCommandRegistry } from "@commands/registry"; +import { createInitialAppState } from "@editor/initial-state"; +import { createAppStore } from "@editor/store"; +import type { RecoveryStorage } from "@platform/projectStorage"; +import { createProjectLifecycle, RECOVERY_STORAGE_KEY } from "./lifecycle"; +import { parseProject, serializeProject } from "./format"; + +describe("project lifecycle", () => { + test("autosaves document changes after the debounce", async () => { + const app = createTestApp(); + const storage = memoryStorage(); + let scheduled: (() => void) | undefined; + const lifecycle = createProjectLifecycle({ + store: app.store, + storage, + schedule(callback) { + scheduled = callback; + return 1 as unknown as ReturnType; + }, + cancel() {}, + }); + + const artboardId = app.store.getState().document.artboards[0]!.id; + app.store.dispatch(commandIds.documentRenameArtboard, { id: artboardId, name: "Recovered board" }); + expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull(); + scheduled?.(); + await Promise.resolve(); + expect(parseProject((await storage.read(RECOVERY_STORAGE_KEY))!).document.artboards[0]!.name).toBe("Recovered board"); + lifecycle.dispose(); + }); + + test("recovers a document through the project command and clears history", async () => { + const app = createTestApp(); + const recovered = createTestApp("Recovered").store.getState().document; + const storage = memoryStorage([[RECOVERY_STORAGE_KEY, serializeProject(recovered)]]); + const lifecycle = createProjectLifecycle({ store: app.store, storage }); + + expect((await lifecycle.recover())?.document.name).toBe("Recovered"); + expect(app.store.getState().document.name).toBe("Recovered"); + expect(app.store.getState().history).toEqual({ past: [], future: [] }); + lifecycle.dispose(); + }); + + test("discards corrupted recovery and clears recovery after explicit save", async () => { + const app = createTestApp(); + const storage = memoryStorage([[RECOVERY_STORAGE_KEY, "broken"]]); + const lifecycle = createProjectLifecycle({ store: app.store, storage }); + + expect(await lifecycle.recover()).toBeUndefined(); + expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull(); + await storage.write(RECOVERY_STORAGE_KEY, lifecycle.snapshot()); + await lifecycle.markSaved(); + expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull(); + lifecycle.dispose(); + }); + + test("explicit save cancels a pending recovery write", async () => { + const app = createTestApp(); + const storage = memoryStorage(); + let scheduled: (() => void) | undefined; + let cancelled = false; + const lifecycle = createProjectLifecycle({ + store: app.store, + storage, + schedule(callback) { + scheduled = callback; + return 1; + }, + cancel() { cancelled = true; }, + }); + + const artboardId = app.store.getState().document.artboards[0]!.id; + app.store.dispatch(commandIds.documentRenameArtboard, { id: artboardId, name: "Saved" }); + await lifecycle.markSaved(); + expect(cancelled).toBe(true); + expect(await storage.read(RECOVERY_STORAGE_KEY)).toBeNull(); + expect(scheduled).toBeDefined(); + lifecycle.dispose(); + }); +}); + +function memoryStorage(entries: [string, string][] = []): RecoveryStorage { + const values = new Map(entries); + return { + read: async (key) => values.get(key) ?? null, + write: async (key, value) => { values.set(key, value); }, + remove: async (key) => { values.delete(key); }, + }; +} + +function createTestApp(name = "Test") { + const store = createAppStore(createInitialAppState(name), createCommandRegistry([projectOpenCommand, documentAddArtboardCommand, documentRenameArtboardCommand])); + store.dispatch(commandIds.documentAddArtboard, { id: `${name}-artboard`, name: "Board", bounds: { x: 0, y: 0, w: 100, h: 100 } }); + return { store }; +} diff --git a/operations/project/lifecycle.ts b/operations/project/lifecycle.ts new file mode 100644 index 0000000..00279a3 --- /dev/null +++ b/operations/project/lifecycle.ts @@ -0,0 +1,86 @@ +import { commandIds } from "@commands/ids"; +import type { AppStore } from "@editor/store"; +import type { ProjectFile } from "./format"; +import { parseProject, serializeProject } from "./format"; +import type { RecoveryStorage } from "@platform/projectStorage"; + +export const RECOVERY_STORAGE_KEY = "image-studio.recovery.v1"; + +type TimerHandle = number | ReturnType; + +export type ProjectLifecycle = { + recover(): Promise; + open(source: string): Promise; + snapshot(): string; + markSaved(): Promise; + dispose(): void; +}; + +export function createProjectLifecycle(options: { + store: AppStore; + storage: RecoveryStorage; + debounceMs?: number; + schedule?: (callback: () => void, delay: number) => TimerHandle; + cancel?: (handle: TimerHandle) => void; +}): ProjectLifecycle { + const debounceMs = options.debounceMs ?? 750; + const schedule = options.schedule ?? setTimeout; + const cancel = options.cancel ?? ((handle: TimerHandle) => clearTimeout(handle)); + let observedDocument = options.store.getState().document; + let pending: TimerHandle | undefined; + let disposed = false; + + const cancelPending = () => { + if (pending === undefined) return; + cancel(pending); + pending = undefined; + }; + + const unsubscribe = options.store.subscribe((state) => { + if (state.document === observedDocument) return; + observedDocument = state.document; + cancelPending(); + pending = schedule(() => { + pending = undefined; + void options.storage.write(RECOVERY_STORAGE_KEY, serializeProject(options.store.getState().document)).catch(() => undefined); + }, debounceMs); + }); + + const replace = (project: ProjectFile) => { + options.store.dispatch(commandIds.projectOpen, { document: project.document }); + cancelPending(); + observedDocument = options.store.getState().document; + return project; + }; + + return { + async recover() { + const source = await options.storage.read(RECOVERY_STORAGE_KEY); + if (!source || disposed) return undefined; + try { + return replace(parseProject(source)); + } catch { + await options.storage.remove(RECOVERY_STORAGE_KEY); + return undefined; + } + }, + async open(source) { + const project = replace(parseProject(source)); + await options.storage.remove(RECOVERY_STORAGE_KEY); + return project; + }, + snapshot() { + return serializeProject(options.store.getState().document); + }, + async markSaved() { + cancelPending(); + observedDocument = options.store.getState().document; + await options.storage.remove(RECOVERY_STORAGE_KEY); + }, + dispose() { + disposed = true; + unsubscribe(); + cancelPending(); + }, + }; +} diff --git a/platform/browser/projectFiles.ts b/platform/browser/projectFiles.ts new file mode 100644 index 0000000..4dce7e2 --- /dev/null +++ b/platform/browser/projectFiles.ts @@ -0,0 +1,55 @@ +import type { RecoveryStorage } from "@platform/projectStorage"; + +export const browserRecoveryStorage: RecoveryStorage = { + async read(key) { + return request("readonly", (store) => store.get(key)).then((value) => value ?? null); + }, + async write(key, value) { + await request("readwrite", (store) => store.put(value, key)); + }, + async remove(key) { + await request("readwrite", (store) => store.delete(key)); + }, +}; + +const RECOVERY_DATABASE = "image-studio"; +const RECOVERY_STORE = "recovery"; + +function openRecoveryDatabase(): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(RECOVERY_DATABASE, 1); + open.onupgradeneeded = () => { + if (!open.result.objectStoreNames.contains(RECOVERY_STORE)) open.result.createObjectStore(RECOVERY_STORE); + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error ?? new Error("Could not open recovery storage.")); + }); +} + +async function request(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest): Promise { + const database = await openRecoveryDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(RECOVERY_STORE, mode); + const databaseRequest = operation(transaction.objectStore(RECOVERY_STORE)); + databaseRequest.onsuccess = () => resolve(databaseRequest.result); + databaseRequest.onerror = () => reject(databaseRequest.error ?? new Error("Recovery storage operation failed.")); + transaction.oncomplete = () => database.close(); + transaction.onerror = () => { + database.close(); + reject(transaction.error ?? new Error("Recovery storage transaction failed.")); + }; + }); +} + +export function downloadProjectFile(source: string, fileName: string): void { + const url = URL.createObjectURL(new Blob([source], { type: "application/json" })); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +export function readProjectFile(file: File): Promise { + return file.text(); +} diff --git a/platform/projectStorage.ts b/platform/projectStorage.ts new file mode 100644 index 0000000..3853ead --- /dev/null +++ b/platform/projectStorage.ts @@ -0,0 +1,5 @@ +export type RecoveryStorage = { + read(key: string): Promise; + write(key: string, value: string): Promise; + remove(key: string): Promise; +}; diff --git a/view/App.tsx b/view/App.tsx index 2b0c1f5..20ce645 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect } from "react"; -import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react"; +import { DownloadSimple, FloppyDisk, FolderOpen, FolderSimple, Sparkle, Stack } from "@phosphor-icons/react"; import type { ImageStudioApp } from "@app/app"; import { commandIds } from "@commands/ids"; import { BottomControlsIsland } from "./BottomControlsIsland"; @@ -19,6 +19,7 @@ import { downloadArtboardPng } from "@operations/export/downloadArtboard"; import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; import { loadGenerationResources } from "@operations/generation/loadResources"; +import { useProjectLifecycle } from "./useProjectLifecycle"; import "./index.css"; export type AppProps = { @@ -30,6 +31,7 @@ export function App({ app }: AppProps) { const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit, workspace } = shellState; const viewportActivityIsland = useViewportActivityIsland(viewport); const imageImport = useImageImport(app.store); + const project = useProjectLifecycle(app.store); const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection); const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0]; const generateOpen = workspace.panel === "generate"; @@ -133,6 +135,7 @@ export function App({ app }: AppProps) { return (
{imageImport.input} + {project.input} + + diff --git a/view/useProjectLifecycle.tsx b/view/useProjectLifecycle.tsx new file mode 100644 index 0000000..92720ea --- /dev/null +++ b/view/useProjectLifecycle.tsx @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { AppStore } from "@editor/store"; +import { createProjectLifecycle } from "@operations/project/lifecycle"; +import { projectFileName } from "@operations/project/format"; +import { browserRecoveryStorage, downloadProjectFile, readProjectFile } from "@platform/browser/projectFiles"; + +export function useProjectLifecycle(store: AppStore) { + const inputRef = useRef(null); + const lifecycleRef = useRef | null>(null); + + useEffect(() => { + const lifecycle = createProjectLifecycle({ store, storage: browserRecoveryStorage }); + lifecycleRef.current = lifecycle; + void lifecycle.recover(); + return () => { + lifecycle.dispose(); + lifecycleRef.current = null; + }; + }, [store]); + + const save = useCallback(() => { + const lifecycle = lifecycleRef.current; + if (!lifecycle) return; + const document = store.getState().document; + downloadProjectFile(lifecycle.snapshot(), projectFileName(document.name)); + void lifecycle.markSaved(); + }, [store]); + + const openFilePicker = useCallback(() => inputRef.current?.click(), []); + + const openFile = useCallback(async (file: File) => { + const lifecycle = lifecycleRef.current; + if (!lifecycle) return; + try { + await lifecycle.open(await readProjectFile(file)); + } catch (error) { + window.alert(error instanceof Error ? error.message : "The project could not be opened."); + } + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || (!event.metaKey && !event.ctrlKey)) return; + if (event.key.toLowerCase() === "s") { + event.preventDefault(); + save(); + } else if (event.shiftKey && event.key.toLowerCase() === "o") { + event.preventDefault(); + openFilePicker(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [openFilePicker, save]); + + const input = ( + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (file) void openFile(file); + }} + /> + ); + + return { input, save, openFilePicker }; +}