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.
This commit is contained in:
62
view/App.tsx
62
view/App.tsx
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
@@ -8,6 +10,7 @@ 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";
|
||||
@@ -23,6 +26,8 @@ export function App({ app }: AppProps) {
|
||||
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) => {
|
||||
@@ -41,6 +46,12 @@ export function App({ app }: AppProps) {
|
||||
|
||||
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();
|
||||
@@ -63,11 +74,39 @@ export function App({ app }: AppProps) {
|
||||
return (
|
||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||
{imageImport.input}
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
||||
<div className="text-xs">
|
||||
{state.document.name} · {labelForTool(state.editor.tools.activeTool)} · {zoomPercent}% · {state.editor.viewport.size.w}×
|
||||
{state.editor.viewport.size.h}
|
||||
<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">
|
||||
@@ -80,7 +119,7 @@ export function App({ app }: AppProps) {
|
||||
<LayersSheet
|
||||
document={state.document}
|
||||
selection={state.editor.selection}
|
||||
maskEditLayerId={state.editor.maskEditLayerId}
|
||||
maskEdit={state.editor.maskEdit}
|
||||
open={layersOpen}
|
||||
dispatch={app.store.dispatch}
|
||||
onClose={() => setLayersOpen(false)}
|
||||
@@ -92,6 +131,8 @@ export function App({ app }: AppProps) {
|
||||
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}
|
||||
@@ -102,4 +143,13 @@ export function App({ app }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user