Files
image-studio/view/App.tsx

269 lines
11 KiB
TypeScript

import { useCallback, useEffect } from "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";
import { brushUnavailableHint } from "@operations/paint/brush";
import { CanvasViewport } from "./CanvasViewport";
import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet";
import { GenerationJobStatus } from "./GenerationJobStatus";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
import { useProjectLifecycle } from "./useProjectLifecycle";
import "./index.css";
export type AppProps = {
app: ImageStudioApp;
};
export function App({ app }: AppProps) {
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
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";
const chromaKeyOpen = workspace.panel === "chromaKey";
const layersOpen = workspace.panel === "layers";
useEffect(() => {
if (generateOpen) void app.workflows.generation.loadResources();
}, [app.workflows.generation, generateOpen]);
const openGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "generate" });
}, [app.store]);
const closeGenerate = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
}, [app.store]);
const toggleGenerate = useCallback(() => {
if (generateOpen) closeGenerate();
else openGenerate();
}, [closeGenerate, generateOpen, openGenerate]);
const openLayers = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "layers" });
}, [app.store]);
const closeLayers = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: "none" });
}, [app.store]);
const toggleLayers = useCallback(() => {
app.store.dispatch(commandIds.workspaceSetPanel, { panel: layersOpen ? "none" : "layers" });
}, [app.store, layersOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const keybindEvent = keybindEventFromKeyboardEvent(event);
const paletteConsumed = handleCommandPaletteKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (paletteConsumed) {
event.preventDefault();
return;
}
if (app.store.getState().editor.commandPalette.open) {
if (event.key === "Escape") {
app.store.dispatch(commandIds.commandPaletteClose, undefined);
event.preventDefault();
}
return;
}
const target = event.target;
const editableTarget =
target instanceof HTMLElement &&
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement);
if (editableTarget) return;
const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (historyConsumed) {
event.preventDefault();
return;
}
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === "Escape" && app.store.getState().editor.maskEdit) {
app.store.dispatch(commandIds.toolExitMaskEdit, undefined);
event.preventDefault();
return;
}
const key = event.key.toLowerCase();
const operationConsumed = handleOperationKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (operationConsumed) {
event.preventDefault();
return;
}
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (toolConsumed) {
event.preventDefault();
return;
}
if (key === "l") {
toggleLayers();
event.preventDefault();
return;
}
const consumed = handleDeleteSelectionKey({
event: keybindEvent,
selection: app.store.getState().editor.selection,
dispatch: app.store.dispatch,
});
if (consumed) event.preventDefault();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store, toggleLayers]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
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}
selection={selection}
viewport={viewport}
tools={tools}
generation={generation}
layersOpen={layersOpen}
activeArtboard={activeArtboard}
dispatch={app.store.dispatch}
openFilePicker={imageImport.openFilePicker}
openGenerate={openGenerate}
openLayers={openLayers}
closeLayers={closeLayers}
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="pointer-events-auto flex items-center gap-2">
<button type="button" className="border-0 bg-transparent p-0" onClick={openGenerate} title="Open generation activity">
<GenerationJobStatus generation={generation} compact />
</button>
<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>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, document.assets)}>
<DownloadSimple size={24} />
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={toggleLayers}>
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
</button>
</div>
</header>
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
<ToolOverlay
activeTool={tools.activeTool}
interactionMode={tools.interactionMode}
panel={workspace.panel}
dispatch={app.store.dispatch}
/>
</div>
<GenerateSheet
settings={tools.generate}
resources={generation.resources}
open={generateOpen}
dispatch={app.store.dispatch}
/>
<LayersSheet
document={document}
selection={selection}
maskEdit={maskEdit}
open={layersOpen}
dispatch={app.store.dispatch}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland
document={document}
selection={selection}
viewport={viewport}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={tools.activeTool}
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
brushSettings={tools.brush}
generateSettings={tools.generate}
generation={generation}
chromaKeySettings={tools.chromaKey}
magicWandSettings={tools.magicWand}
editingMask={Boolean(maskEdit)}
maskViewMode={maskEdit?.viewMode ?? "composite"}
brushHint={brushHint}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch}
generationWorkflow={app.workflows.generation}
/>
</div>
<div className="absolute bottom-4 left-4 z-10">
<ShortcutsDisplay />
</div>
<CanvasViewport store={app.store} />
</main>
);
}
function topBarButtonClass(active = false) {
const base = "inline-flex size-12 items-center justify-center rounded-full text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
}
type AppShellState = {
document: AppState["document"];
selection: AppState["editor"]["selection"];
viewport: AppState["editor"]["viewport"];
tools: AppState["editor"]["tools"];
generation: AppState["editor"]["generation"];
commandPalette: AppState["editor"]["commandPalette"];
transformSession: AppState["editor"]["transformSession"];
maskEdit: AppState["editor"]["maskEdit"];
workspace: AppState["editor"]["workspace"];
};
function selectAppShellState(state: AppState): AppShellState {
return {
document: state.document,
selection: state.editor.selection,
viewport: state.editor.viewport,
tools: state.editor.tools,
generation: state.editor.generation,
commandPalette: state.editor.commandPalette,
transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit,
workspace: state.editor.workspace,
};
}
export default App;