- Implemented feather brush tool in the brushRaster module, allowing for feathered edges in brush strokes. - Added new FeatherControls component for UI adjustments of feather settings including size, radius, strength, and smoothing. - Updated brush preview logic to accommodate feather tool alongside existing brush and eraser tools. - Enhanced layer rendering to support feather mask previews and interactions. - Introduced blending logic for feathered strokes to mix blurred mask values with original pixels. - Added unit tests for feather blending functionality and tool keybindings. - Updated cursor handling to reflect feather tool usage.
281 lines
12 KiB
TypeScript
281 lines
12 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 { 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 { 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(() => {
|
|
window.document.title = `${document.name} — Image Studio`;
|
|
}, [document.name]);
|
|
|
|
useEffect(() => {
|
|
if (generateOpen) void app.workflows.generation.loadResources();
|
|
}, [app.workflows.generation, generateOpen]);
|
|
|
|
const openGenerate = useCallback(() => {
|
|
app.actions.document.openGenerate();
|
|
}, [app.actions.document]);
|
|
|
|
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, actions: app.actions.document });
|
|
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.actions.document, 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_50%_35%,rgba(51,65,85,0.28),transparent_48%),linear-gradient(145deg,#080b10_0%,#10151d_52%,#0a0e14_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}
|
|
documentActions={app.actions.document}
|
|
/>
|
|
<header className="app-surface pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-4 rounded-xl px-2.5 text-white">
|
|
<div className="min-w-0 px-1.5">
|
|
<p className="text-[0.58rem] font-semibold uppercase tracking-[0.18em] text-white/35">Image Studio</p>
|
|
<h1 className="truncate text-xs font-medium text-white/85" title={document.name}>{document.name}</h1>
|
|
</div>
|
|
<div className="pointer-events-auto flex items-center gap-2">
|
|
<button type="button" className="rounded-full border-0 bg-transparent p-0" onClick={openGenerate} aria-label="Open generation activity" title="Open generation activity">
|
|
<GenerationJobStatus generation={generation} compact />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker} aria-label="Open image" title="Open image (Cmd/Ctrl+O)">
|
|
<FolderOpen size={18} />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} aria-label="Open project" title="Open project (Cmd/Ctrl+Shift+O)">
|
|
<FolderSimple size={18} />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass()} onClick={project.save} aria-label="Save project" title="Save project (Cmd/Ctrl+S)">
|
|
<FloppyDisk size={18} />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass(generateOpen)} aria-label="Generate" aria-pressed={generateOpen} aria-controls="generate-sheet" onClick={toggleGenerate} title="Generate (G)">
|
|
<Sparkle size={18} weight={generateOpen ? "fill" : "regular"} />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass()} aria-label="Export active artboard as PNG" title="Export active artboard as PNG" disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
|
|
<DownloadSimple size={18} />
|
|
</button>
|
|
<button type="button" className={topBarButtonClass(layersOpen)} aria-label="Layers" aria-pressed={layersOpen} aria-controls="layers-sheet" onClick={toggleLayers} title="Layers (L)">
|
|
<Stack size={18} 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}
|
|
inpaintMaskEditing={maskEdit?.kind === "inpaintRegion"}
|
|
dispatch={app.store.dispatch}
|
|
/>
|
|
</div>
|
|
<GenerateSheet
|
|
settings={tools.generate}
|
|
document={document}
|
|
selection={selection}
|
|
resources={generation.resources}
|
|
generation={generation}
|
|
open={generateOpen}
|
|
dispatch={app.store.dispatch}
|
|
/>
|
|
<LayersSheet
|
|
document={document}
|
|
selection={selection}
|
|
maskEdit={maskEdit}
|
|
open={layersOpen}
|
|
dispatch={app.store.dispatch}
|
|
documentActions={app.actions.document}
|
|
/>
|
|
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center">
|
|
<BottomControlsIsland
|
|
document={document}
|
|
selection={selection}
|
|
viewport={viewport}
|
|
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "feather" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
|
action={viewportActivityIsland.action}
|
|
activeTool={tools.activeTool}
|
|
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
|
|
brushSettings={tools.brush}
|
|
featherSettings={tools.feather}
|
|
generateSettings={tools.generate}
|
|
generation={generation}
|
|
chromaKeySettings={tools.chromaKey}
|
|
magicWandSettings={tools.magicWand}
|
|
editingMask={Boolean(maskEdit)}
|
|
maskKind={maskEdit?.kind}
|
|
maskViewMode={maskEdit?.viewMode ?? "composite"}
|
|
brushHint={brushHint}
|
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
|
dispatch={app.store.dispatch}
|
|
generationWorkflow={app.workflows.generation}
|
|
documentActions={app.actions.document}
|
|
/>
|
|
</div>
|
|
<CanvasViewport store={app.store} />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function topBarButtonClass(active = false) {
|
|
const base = "inline-flex size-8 items-center justify-center rounded-lg text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
|
|
return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/60 hover:bg-white/[0.07] 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;
|