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;
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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}`} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user