feat: add project management features including open/save functionality and recovery support
This commit is contained in:
11
view/App.tsx
11
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 (
|
||||
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
|
||||
{imageImport.input}
|
||||
{project.input}
|
||||
<CommandPalette
|
||||
state={commandPalette}
|
||||
document={document}
|
||||
@@ -156,6 +159,12 @@ export function App({ app }: AppProps) {
|
||||
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
|
||||
<FolderOpen size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} title="Open project (Cmd/Ctrl+Shift+O)">
|
||||
<FolderSimple size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass()} onClick={project.save} title="Save project (Cmd/Ctrl+S)">
|
||||
<FloppyDisk size={24} />
|
||||
</button>
|
||||
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
|
||||
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
71
view/useProjectLifecycle.tsx
Normal file
71
view/useProjectLifecycle.tsx
Normal file
@@ -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<HTMLInputElement | null>(null);
|
||||
const lifecycleRef = useRef<ReturnType<typeof createProjectLifecycle> | 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 = (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".json,.image-studio.json,application/json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
if (file) void openFile(file);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return { input, save, openFilePicker };
|
||||
}
|
||||
Reference in New Issue
Block a user