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:
syntaxbullet
2026-07-03 20:49:29 +02:00
parent daaa2a6667
commit 4ad0bb8b2c
33 changed files with 1889 additions and 351 deletions

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
import type { AppStore } from "@editor/store";
import type { ViewportState } from "@editor/state";
import type { MaskViewMode, ViewportState } from "@editor/state";
import type { BrushSettings, ToolId } from "@editor/tools";
import { BrushControls } from "./bottom-controls/BrushControls";
import { PanControls } from "./bottom-controls/PanControls";
@@ -15,12 +15,14 @@ export type BottomControlsIslandProps = {
action: BottomControlsAction;
activeTool: ToolId;
brushSettings: BrushSettings;
editingMask?: boolean;
maskViewMode?: MaskViewMode;
transformBounds?: Rect;
transformTarget?: TransformTarget;
dispatch: AppStore["dispatch"];
};
export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) {
export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) {
const zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y);
@@ -33,7 +35,7 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
}`}
>
{activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} dispatch={dispatch} />
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
) : action === "pan" ? (

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from "react";
import type { AppStore } from "@editor/store";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import { canPreviewBrush } from "./canvas/brush";
import { canvasCursorClass } from "./canvas/cursor";
import { useCanvasInput } from "./canvas/useCanvasInput";
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
@@ -34,7 +35,8 @@ export function CanvasViewport({
useCanvasRenderer(canvasRef, store);
useCanvasResize(canvasRef, store.dispatch);
const input = useCanvasInput(canvasRef, store, inputOptions);
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input);
const hasBrushPreview = Boolean(state.editor.brushPreview && canPreviewBrush(state.document, state.editor));
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview);
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
}

View File

@@ -1,35 +1,37 @@
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash, X } from "@phosphor-icons/react";
import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ArtboardId } from "@core/id";
import type { SelectionState } from "@editor/state";
import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
export type LayersSheetProps = {
document: ImageDocument;
selection: SelectionState;
maskEditLayerId?: string;
maskEdit?: MaskEditState;
open: boolean;
dispatch: AppStore["dispatch"];
onClose: () => void;
};
export function LayersSheet({ document, selection, maskEditLayerId, open, dispatch, onClose }: LayersSheetProps) {
export function LayersSheet({ document, selection, maskEdit, open, dispatch, onClose }: LayersSheetProps) {
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
const canUngroup = selectedLayer?.layer.type === "group";
const maskLayerIds = collectDocumentMaskLayerIds(document);
const draggedLayerId = useRef<string>();
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return (
<aside
aria-hidden={!open}
className={`pointer-events-auto absolute right-4 top-12 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white shadow-2xl shadow-black/40 backdrop-blur-xl transition-all duration-200 ${
className={`pointer-events-auto absolute right-4 top-16 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
}`}
>
@@ -38,9 +40,7 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
<div className="font-medium tracking-wide">Layers</div>
<div className="text-xs text-white/40">Press L to open</div>
</div>
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
<X size={18} weight="regular" />
</button>
</header>
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
<button type="button" className={toolbarButtonClass()} onClick={() => addArtboard(document, dispatch)}>
@@ -120,10 +120,10 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
>
<DownloadSimple size={17} weight="regular" />
</button>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{artboard.layers.length}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
</div>
<div className="mt-2 space-y-1.5 pl-3">
{artboard.layers.length === 0 ? (
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
) : (
artboard.layers.map((layer) => (
@@ -137,8 +137,8 @@ export function LayersSheet({ document, selection, maskEditLayerId, open, dispat
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedLayer}
maskEditLayerId={maskEditLayerId}
maskLayerIds={maskLayerIds}
maskEdit={maskEdit}
dispatch={dispatch}
/>
))
@@ -160,8 +160,8 @@ function LayerRow({
draggedLayerId,
editingTitle,
setEditingTitle,
selectedMaskLayer,
maskEditLayerId,
maskLayerIds,
maskEdit,
dispatch,
}: {
document: ImageDocument;
@@ -172,23 +172,25 @@ function LayerRow({
draggedLayerId: MutableRefObject<string | undefined>;
editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
selectedMaskLayer?: LayerInfo;
maskEditLayerId?: string;
maskLayerIds: ReadonlySet<string>;
maskEdit?: MaskEditState;
dispatch: AppStore["dispatch"];
}) {
if (maskLayerIds.has(layer.id)) return null;
const selected = selectedLayerIds.includes(layer.id);
const layerInfo = findLayerInfoInDocument(document, layer.id);
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
const maskIndent = maskLayer ? 24 : 0;
const canSetMask =
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
const editingMask = maskEditLayerId === layer.clippingMask?.maskLayerId;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
const rowPadding = 12 + depth * 16;
return (
<div>
<div
draggable
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: 12 + depth * 16 + maskIndent }}
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${editingMask ? "bg-sky-300 text-black shadow-sm" : selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", layer.id);
@@ -207,8 +209,7 @@ function LayerRow({
draggedLayerId.current = undefined;
}}
>
{maskLayer ? <span className={selected ? "text-black/40" : "text-sky-200/55"}></span> : null}
<button type="button" className={selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
</button>
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
@@ -231,30 +232,46 @@ function LayerRow({
{layer.name}
</button>
)}
<button
type="button"
className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"}
aria-label={layer.clippingMask ? "Clear layer mask" : "Use selected layer as mask"}
title={layer.clippingMask ? "Clear mask" : "Use selected layer as mask"}
disabled={!layer.clippingMask && !canSetMask}
onClick={() => dispatch(commandIds.documentSetLayerClippingMask, { layerId: layer.id, maskLayerId: layer.clippingMask ? undefined : selectedMaskLayer?.layer.id })}
>
<Stack size={17} weight={layer.clippingMask ? "fill" : "regular"} />
</button>
<button type="button" className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.clippingMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-2 py-0.5 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-2 py-0.5 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(document, layerInfo, dispatch)}
>
Add mask
</button>
) : null}
<button type="button" className={editingMask || selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
</button>
</div>
{maskLayer ? (
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
<span className="h-px w-5 bg-sky-200/25" />
<span>masked by {maskLayer.name}</span>
{layer.clippingMask ? (
<div className="mt-1 flex items-center gap-2 rounded-xl border border-sky-300/10 bg-sky-400/[0.04] px-3 py-1.5 text-xs text-sky-100/70" style={{ marginLeft: rowPadding + 24 }}>
<Stack size={14} weight="fill" />
<span className="min-w-0 flex-1 truncate">{maskLayer ? `Layer mask · ${maskLayer.name}` : "Layer mask missing"}</span>
{maskLayer ? (
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
}
>
{editingMask ? "Done" : "Edit"}
</button>
) : null}
<button
type="button"
className={`rounded-full px-2 py-0.5 transition ${editingMask ? "bg-sky-300 text-black" : "bg-sky-400/10 text-sky-100/75 hover:bg-sky-400/20 hover:text-sky-50"}`}
onClick={() => dispatch(commandIds.toolSetMaskEditLayer, { layerId: editingMask ? undefined : layer.clippingMask?.maskLayerId })}
className="rounded-full px-2 py-0.5 text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
{editingMask ? "Editing mask" : "Edit mask"}
Remove
</button>
</div>
) : null}
@@ -270,8 +287,8 @@ function LayerRow({
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedMaskLayer}
maskEditLayerId={maskEditLayerId}
maskLayerIds={maskLayerIds}
maskEdit={maskEdit}
dispatch={dispatch}
/>
))
@@ -304,6 +321,69 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
);
}
function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) {
const layer = layerInfo.layer;
if (layer.type === "group") return;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
if (!asset || !bounds) return;
const assetId = crypto.randomUUID();
const maskLayerId = crypto.randomUUID();
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
dispatch(commandIds.documentAddLayerMask, {
layerId: layer.id,
asset: {
id: assetId,
name: `${layer.name} Mask`,
mimeType: "image/svg+xml",
source,
intrinsicSize: { w: width, h: height },
},
maskLayer: {
id: maskLayerId,
type: "raster",
name: `${layer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: { x: bounds.x, y: bounds.y },
scale: { x: bounds.w / width, y: bounds.h / height },
rotation: layer.transform.rotation,
},
},
});
}
function collectDocumentMaskLayerIds(document: ImageDocument): Set<string> {
const ids = new Set<string>();
for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids);
return ids;
}
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>) {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<string>): number {
let count = 0;
for (const layer of layers) {
if (maskLayerIds.has(layer.id)) continue;
count += 1;
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
}
return count;
}
function dropLayer(
document: ImageDocument,
sourceLayerId: string,

View File

@@ -1,5 +1,6 @@
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { MaskViewMode } from "@editor/state";
import type { BrushSettings, ToolId } from "@editor/tools";
import { BottomControlDivider } from "./Divider";
import { bottomControlLabelClass } from "./styles";
@@ -7,15 +8,17 @@ import { bottomControlLabelClass } from "./styles";
export type BrushControlsProps = {
tool: Extract<ToolId, "brush" | "eraser">;
settings: BrushSettings;
editingMask?: boolean;
maskViewMode?: MaskViewMode;
dispatch: AppStore["dispatch"];
};
export function BrushControls({ tool, settings, dispatch }: BrushControlsProps) {
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className="px-2 font-medium text-white/85">{tool === "eraser" ? "Eraser" : "Brush"}</span>
<span className="px-2 font-medium text-white/85">{editingMask ? `Mask · ${tool === "eraser" ? "Hide" : "Reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}</span>
<BottomControlDivider />
{tool === "brush" ? (
{tool === "brush" && !editingMask ? (
<label className="flex items-center gap-1.5">
<span className={bottomControlLabelClass()}>Color</span>
<input
@@ -54,6 +57,33 @@ export function BrushControls({ tool, settings, dispatch }: BrushControlsProps)
/>
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
</label>
{editingMask ? (
<>
<BottomControlDivider />
<label className="flex items-center gap-1.5">
<span className={bottomControlLabelClass()}>View</span>
<select
className="h-7 rounded-full border border-white/10 bg-black/70 px-2 text-white outline-none"
value={maskViewMode}
aria-label="Mask view mode"
onChange={(event) => dispatch(commandIds.toolSetMaskViewMode, { mode: event.target.value as MaskViewMode })}
>
<option value="composite">Image</option>
<option value="blackWhite">B/W</option>
<option value="alpha">Alpha</option>
<option value="overlay">Overlay</option>
</select>
</label>
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-3 py-1 font-medium text-black transition hover:bg-white/90"
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
>
Done
</button>
</>
) : null}
</div>
);
}

View File

@@ -9,48 +9,88 @@ import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
assetId: string;
previousPoint: Vec2D;
mode: "brush" | "eraser";
source?: string;
pending?: Promise<void>;
cancelled?: boolean;
};
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const layerId = editor.maskEditLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || !layer.visible) return undefined;
return { layerId, previousPoint: point, mode: editor.tools.activeTool };
const layer = resolveBrushTargetLayer(document, editor);
if (!layer || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
return { layerId: layer.id, assetId: layer.assetId, previousPoint: point, mode: editor.tools.activeTool };
}
export async function updateBrushSession(options: {
export function canPreviewBrush(document: ImageDocument, editor: EditorState): boolean {
return Boolean(resolveBrushTargetLayer(document, editor));
}
function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
}
export function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
color: string;
size: number;
hardness: number;
}): Promise<BrushSession> {
}): BrushSession {
const state = options.store.getState();
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
if (!layer) return { ...options.session, previousPoint: options.point };
if (!layer) return options.session;
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return { ...options.session, previousPoint: options.point };
if (!asset) return options.session;
const source = await drawStroke({
source: asset.source,
width: asset.intrinsicSize.w,
height: asset.intrinsicSize.h,
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
color: state.editor.maskEditLayerId ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
const from = options.session.previousPoint;
const to = options.point;
options.session.previousPoint = to;
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
if (options.session.cancelled) return;
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
return { ...options.session, previousPoint: options.point };
const source = await drawStroke({
source: options.session.source ?? asset.source,
width: asset.intrinsicSize.w,
height: asset.intrinsicSize.h,
from: documentPointToAssetPoint(from, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
to: documentPointToAssetPoint(to, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
if (options.session.cancelled) return;
options.session.source = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
})
.catch(() => undefined);
return options.session;
}
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
await options.session.pending;
if (options.session.cancelled) return;
if (options.session.source) options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source: options.session.source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {

View File

@@ -2,9 +2,10 @@ import type { InteractionMode } from "@editor/tools";
import { isPanInteractionMode } from "@editor/tools";
import type { CanvasInputState } from "./useCanvasInput";
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false) {
if (input.isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && (interactionMode.tool === "crop" || interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return "cursor-crosshair";
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair";
return "cursor-default";
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { isPanInteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
@@ -11,7 +12,7 @@ import {
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "@input/index";
import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush";
import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
@@ -50,9 +51,31 @@ export function useCanvasInput(
isPanMode: () => isPanInteractionMode(store.getState().editor.tools.interactionMode),
});
const clearBrushPreview = () => {
if (store.getState().editor.brushPreview) store.dispatch(commandIds.toolSetBrushPreview, undefined);
};
const updateBrushPreview = (position: { x: number; y: number }) => {
if (!pointInsideCanvas(position, canvas)) {
clearBrushPreview();
return;
}
const state = store.getState();
if (!canPreviewBrush(state.document, state.editor)) {
clearBrushPreview();
return;
}
store.dispatch(commandIds.toolSetBrushPreview, { position: viewportPointToDocumentPoint(position, state.editor.viewport) });
};
const handleKeyDown = (event: KeyboardEvent) => {
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
if (consumed) event.preventDefault();
if (consumed) {
clearBrushPreview();
event.preventDefault();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
@@ -64,6 +87,7 @@ export function useCanvasInput(
const inputEvent = pointerInputEventFromPointerEvent(event);
const transformed = transformHandler.pointerDown(inputEvent);
if (transformed) {
clearBrushPreview();
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
return;
@@ -71,6 +95,7 @@ export function useCanvasInput(
const consumed = panHandler.pointerDown(inputEvent);
if (consumed) {
clearBrushPreview();
canvas.setPointerCapture(event.pointerId);
setIsPanning(true);
event.preventDefault();
@@ -78,10 +103,12 @@ export function useCanvasInput(
}
const state = store.getState();
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
? beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport))
? beginBrushSession(state.document, state.editor, documentPoint)
: undefined;
if (brush) {
store.dispatch(commandIds.toolSetBrushPreview, { position: documentPoint });
brushSessionId.current += 1;
brushSession.current = brush;
canvas.setPointerCapture(event.pointerId);
@@ -104,43 +131,53 @@ export function useCanvasInput(
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
const session = brushSession.current;
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
event.preventDefault();
return;
}
const activeSessionId = brushSessionId.current;
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
const settings = store.getState().editor.tools.brush;
void updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }).then((nextSession) => {
if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
});
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
event.preventDefault();
return;
}
const transformed = transformHandler.pointerMove(inputEvent);
if (transformed) {
clearBrushPreview();
event.preventDefault();
return;
}
const consumed = panHandler.pointerMove(inputEvent);
if (consumed) event.preventDefault();
if (consumed) {
clearBrushPreview();
event.preventDefault();
return;
}
updateBrushPreview(inputEvent.position);
};
const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
const session = brushSession.current;
brushSessionId.current += 1;
brushSession.current = undefined;
void commitBrushSession({ store, session }).then(() => updateBrushPreview(inputEvent.position));
event.preventDefault();
return;
}
const transformed = transformHandler.pointerUp(inputEvent);
if (transformed) {
clearBrushPreview();
event.preventDefault();
return;
}
@@ -149,9 +186,14 @@ export function useCanvasInput(
if (!consumed) return;
setIsPanning(false);
clearBrushPreview();
event.preventDefault();
};
const handlePointerLeave = () => {
if (!brushSession.current) clearBrushPreview();
};
const handleWheel = (event: WheelEvent) => {
const consumed = handleViewportWheel({
event: wheelInputEventFromWheelEvent(event),
@@ -169,6 +211,7 @@ export function useCanvasInput(
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerUp);
canvas.addEventListener("pointerleave", handlePointerLeave);
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
@@ -178,6 +221,7 @@ export function useCanvasInput(
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointercancel", handlePointerUp);
canvas.removeEventListener("pointerleave", handlePointerLeave);
canvas.removeEventListener("wheel", handleWheel);
};
}, [canvasRef, options, store]);
@@ -185,6 +229,10 @@ export function useCanvasInput(
return { isPanning };
}
function pointInsideCanvas(point: { x: number; y: number }, canvas: HTMLCanvasElement) {
return point.x >= 0 && point.y >= 0 && point.x <= canvas.width && point.y <= canvas.height;
}
function viewportPointToDocumentPoint(point: { x: number; y: number }, viewport: { center: { x: number; y: number }; size: { w: number; h: number }; zoom: number }) {
return {
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,