diff --git a/renderer/layers.ts b/renderer/layers.ts index 54904fb..039df1a 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -17,7 +17,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum if (!artboard.visible) continue; const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); const maskLayerIds = collectMaskLayerIds(artboard.layers); - for (const layer of artboard.layers) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds); + for (const layer of renderStack(artboard.layers)) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds); } } @@ -39,7 +39,7 @@ function renderLayer( if (!effectiveClipRect) return; if (layer.type === "group") { - for (const child of layer.children) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds); + for (const child of renderStack(layer.children)) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds); return; } @@ -79,6 +79,10 @@ function renderLayer( if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); } +function renderStack(layers: readonly Layer[]) { + return [...layers].reverse(); +} + function resolveLayerClipRect( context: WebGlRendererContext, document: ImageDocument, diff --git a/view/App.tsx b/view/App.tsx index 7cc24cd..aed9b8a 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -3,6 +3,7 @@ import { DownloadSimple, FolderOpen, Stack } from "@phosphor-icons/react"; import type { ImageStudioApp } from "@app/app"; import { commandIds } from "@commands/ids"; import { BottomControlsIsland } from "./BottomControlsIsland"; +import { brushUnavailableHint } from "./canvas/brush"; import { CanvasViewport } from "./CanvasViewport"; import { LayersSheet } from "./LayersSheet"; import { ToolOverlay } from "./ToolOverlay"; @@ -67,6 +68,7 @@ export function App({ app }: AppProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [app.store]); const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined; + const brushHint = brushUnavailableHint(state.document, state.editor); return (
@@ -107,6 +109,7 @@ export function App({ app }: AppProps) { brushSettings={state.editor.tools.brush} editingMask={Boolean(state.editor.maskEdit)} maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"} + brushHint={brushHint} transformBounds={viewportActivityIsland.visible ? undefined : transformBounds} transformTarget={viewportActivityIsland.visible ? undefined : transformTarget} dispatch={app.store.dispatch} diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 0b05e12..5ecddad 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -19,10 +19,11 @@ export type BottomControlsIslandProps = { maskViewMode?: MaskViewMode; transformBounds?: Rect; transformTarget?: TransformTarget; + brushHint?: string; dispatch: AppStore["dispatch"]; }; -export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) { +export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) { const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -34,7 +35,9 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0" }`} > - {activeTool === "brush" || activeTool === "eraser" ? ( + {(activeTool === "brush" || activeTool === "eraser") && brushHint ? ( + + ) : activeTool === "brush" || activeTool === "eraser" ? ( ) : transformBounds && transformTarget ? ( @@ -46,3 +49,12 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br ); } + +function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) { + return ( +
+ {tool} + {hint} +
+ ); +} diff --git a/view/CanvasViewport.tsx b/view/CanvasViewport.tsx index aa5eb08..318e9e2 100644 --- a/view/CanvasViewport.tsx +++ b/view/CanvasViewport.tsx @@ -1,7 +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 { brushUnavailableHint, canPreviewBrush } from "./canvas/brush"; import { canvasCursorClass } from "./canvas/cursor"; import { useCanvasInput } from "./canvas/useCanvasInput"; import { useCanvasRenderer } from "./canvas/useCanvasRenderer"; @@ -35,8 +35,9 @@ export function CanvasViewport({ useCanvasRenderer(canvasRef, store); useCanvasResize(canvasRef, store.dispatch); const input = useCanvasInput(canvasRef, store, inputOptions); - const hasBrushPreview = Boolean(state.editor.brushPreview && canPreviewBrush(state.document, state.editor)); - const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview); + const brushHint = brushUnavailableHint(state.document, state.editor); + const hasBrushPreview = Boolean(state.editor.brushPreview && !brushHint && canPreviewBrush(state.document, state.editor)); + const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input, hasBrushPreview, !brushHint); return ; } diff --git a/view/canvas/brush.ts b/view/canvas/brush.ts index fdca2d8..cec7161 100644 --- a/view/canvas/brush.ts +++ b/view/canvas/brush.ts @@ -27,6 +27,25 @@ export function canPreviewBrush(document: ImageDocument, editor: EditorState): b return Boolean(resolveBrushTargetLayer(document, editor)); } +export function brushUnavailableHint(document: ImageDocument, editor: EditorState): string | undefined { + if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; + if (resolveBrushTargetLayer(document, editor)) return undefined; + + const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0]; + if (!layerId) { + if (editor.selection.artboardId) return "Brushes paint layers, not artboards. Select or add a raster layer first."; + return "Select a raster layer or layer mask to paint."; + } + + const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + if (!layer) return "Select a raster layer or layer mask to paint."; + if (layer.locked) return "Unlock this layer before painting."; + if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase."; + if (layer.type === "group") return "Select a raster layer inside the group to paint."; + if (!editor.maskEdit && !layer.visible) return "Show this layer before painting."; + return "Select a raster layer or layer mask to paint."; +} + 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); @@ -146,10 +165,15 @@ function loadImage(source: string) { } function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined { + const layer = findLayer(layers, layerId); + return layer?.type === "raster" ? layer : undefined; +} + +function findLayer(layers: Layer[], layerId: string): Layer | undefined { for (const layer of layers) { - if (layer.id === layerId && layer.type === "raster") return layer; + if (layer.id === layerId) return layer; if (layer.type === "group") { - const child = findRasterLayer(layer.children, layerId); + const child = findLayer(layer.children, layerId); if (child) return child; } } diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 4a94c7b..304ed78 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -2,9 +2,12 @@ import type { InteractionMode } from "@editor/tools"; import { isPanInteractionMode } from "@editor/tools"; import type { CanvasInputState } from "./useCanvasInput"; -export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false) { +export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState, hasBrushPreview = false, canBrush = true) { if (input.isPanning) return "cursor-grabbing"; if (isPanInteractionMode(interactionMode)) return "cursor-grab"; - if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) return hasBrushPreview ? "cursor-none" : "cursor-crosshair"; + if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) { + if (!canBrush) return "cursor-not-allowed"; + return hasBrushPreview ? "cursor-none" : "cursor-crosshair"; + } return "cursor-default"; } diff --git a/view/exportArtboardPng.ts b/view/exportArtboardPng.ts index de4238b..2afe850 100644 --- a/view/exportArtboardPng.ts +++ b/view/exportArtboardPng.ts @@ -21,7 +21,7 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A const maskLayerIds = collectMaskLayerIds(artboard.layers); context.save(); context.translate(-artboard.bounds.x, -artboard.bounds.y); - for (const layer of artboard.layers) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds }); + for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds }); context.restore(); const url = canvas.toDataURL("image/png"); @@ -52,7 +52,7 @@ async function drawLayer( context.globalAlpha *= layer.opacity; if (layer.type === "group") { - for (const child of layer.children) await drawLayer(context, child, layerTree, assets, artboardBounds, options); + for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options); context.restore(); return; } @@ -101,6 +101,10 @@ async function drawMaskedLayer( context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y); } +function renderStack(layers: readonly Layer[]) { + return [...layers].reverse(); +} + function createArtboardCanvas(bounds: Rect) { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(bounds.w));