Files
image-studio/view/App.tsx
syntaxbullet 4ad0bb8b2c feat: enhance layer masking functionality and brush controls
- Refactor LayersSheet component to support mask editing state and improve layer visibility handling.
- Introduce functions to collect mask layer IDs and count display layers excluding masks.
- Update BrushControls to include mask view mode options and a Done button for exiting mask editing.
- Modify brush session handling to support brush previews when editing masks.
- Implement a new BrushPreviewRenderer for rendering brush strokes with visual feedback.
- Add document geometry utilities for transforming points and resolving layer bounds.
- Ensure proper cleanup of brush preview on pointer leave and other interactions.
2026-07-03 20:49:29 +02:00

156 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { DownloadSimple, FolderOpen, ImageSquare, Stack } from "@phosphor-icons/react";
import type { ImageStudioApp } from "@app/app";
import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland";
import { CanvasViewport } from "./CanvasViewport";
import { LayersSheet } from "./LayersSheet";
import { ToolOverlay } from "./ToolOverlay";
import { labelForTool } from "./toolLabels";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import { handleDeleteSelectionKey, handleHistoryKey, keybindEventFromKeyboardEvent } from "@input/index";
import { useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
import "./index.css";
export type AppProps = {
app: ImageStudioApp;
};
export function App({ app }: AppProps) {
const state = useAppState(app.store);
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
const activeArtboard = state.document.artboards.find((artboard) => artboard.id === state.editor.selection.artboardId) ?? state.document.artboards[0];
const activeToolLabel = state.editor.maskEdit ? "Mask edit" : labelForTool(state.editor.tools.activeTool);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
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: keybindEventFromKeyboardEvent(event), 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;
}
if (event.key.toLowerCase() === "l") {
setLayersOpen(true);
event.preventDefault();
return;
}
const consumed = handleDeleteSelectionKey({
event: keybindEventFromKeyboardEvent(event),
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]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
return (
<main className="relative h-full overflow-hidden bg-background text-foreground">
{imageImport.input}
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/85 px-2.5 text-white shadow-2xl shadow-black/35 backdrop-blur-xl">
<div className="flex min-w-0 items-center gap-3 pl-1.5">
<div className="grid size-7 place-items-center rounded-lg bg-white text-black shadow-sm">
<ImageSquare size={18} weight="fill" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-4 tracking-wide">Image Studio</h1>
<div className="truncate text-[11px] leading-4 text-white/45">{state.document.name}</div>
</div>
</div>
<div className="hidden min-w-0 flex-1 items-center justify-center gap-1.5 md:flex">
<span className={topBarChipClass()}>{activeToolLabel}</span>
<span className={topBarChipClass()}>{zoomPercent}%</span>
<span className={topBarChipClass()}>
{state.editor.viewport.size.w}×{state.editor.viewport.size.h}
</span>
{activeArtboard ? <span className="truncate rounded-full bg-sky-400/15 px-3 py-1 text-xs font-medium text-sky-100 ring-1 ring-sky-300/20">{activeArtboard.name}</span> : null}
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
<FolderOpen size={16} />
<span className="hidden sm:inline">Import</span>
</button>
<button type="button" className={topBarButtonClass()} disabled={!activeArtboard} onClick={() => activeArtboard && void downloadArtboardPng(activeArtboard, state.document.assets)}>
<DownloadSimple size={16} />
<span className="hidden sm:inline">Export</span>
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-pressed={layersOpen} onClick={() => setLayersOpen((open) => !open)}>
<Stack size={16} weight={layersOpen ? "fill" : "regular"} />
<span className="hidden sm:inline">Layers</span>
</button>
</div>
</header>
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
<ToolOverlay
activeTool={state.editor.tools.activeTool}
interactionMode={state.editor.tools.interactionMode}
dispatch={app.store.dispatch}
/>
</div>
<LayersSheet
document={state.document}
selection={state.editor.selection}
maskEdit={state.editor.maskEdit}
open={layersOpen}
dispatch={app.store.dispatch}
onClose={() => setLayersOpen(false)}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland
viewport={state.editor.viewport}
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={state.editor.tools.activeTool}
brushSettings={state.editor.tools.brush}
editingMask={Boolean(state.editor.maskEdit)}
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch}
/>
</div>
<CanvasViewport store={app.store} />
</main>
);
}
function topBarChipClass() {
return "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs font-medium text-white/70";
}
function topBarButtonClass(active = false) {
const base = "inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs 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`;
}
export default App;