feat: implement brush availability hints and enhance layer rendering order

This commit is contained in:
syntaxbullet
2026-07-03 23:18:48 +02:00
parent e8073e6146
commit cbd4eb5e9b
7 changed files with 64 additions and 13 deletions

View File

@@ -17,7 +17,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
if (!artboard.visible) continue; if (!artboard.visible) continue;
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
const maskLayerIds = collectMaskLayerIds(artboard.layers); 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 (!effectiveClipRect) return;
if (layer.type === "group") { 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; return;
} }
@@ -79,6 +79,10 @@ function renderLayer(
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
} }
function renderStack(layers: readonly Layer[]) {
return [...layers].reverse();
}
function resolveLayerClipRect( function resolveLayerClipRect(
context: WebGlRendererContext, context: WebGlRendererContext,
document: ImageDocument, document: ImageDocument,

View File

@@ -3,6 +3,7 @@ import { DownloadSimple, FolderOpen, Stack } from "@phosphor-icons/react";
import type { ImageStudioApp } from "@app/app"; import type { ImageStudioApp } from "@app/app";
import { commandIds } from "@commands/ids"; import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland"; import { BottomControlsIsland } from "./BottomControlsIsland";
import { brushUnavailableHint } from "./canvas/brush";
import { CanvasViewport } from "./CanvasViewport"; import { CanvasViewport } from "./CanvasViewport";
import { LayersSheet } from "./LayersSheet"; import { LayersSheet } from "./LayersSheet";
import { ToolOverlay } from "./ToolOverlay"; import { ToolOverlay } from "./ToolOverlay";
@@ -67,6 +68,7 @@ export function App({ app }: AppProps) {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store]); }, [app.store]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined; const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
const brushHint = brushUnavailableHint(state.document, state.editor);
return ( return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground"> <main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
@@ -107,6 +109,7 @@ export function App({ app }: AppProps) {
brushSettings={state.editor.tools.brush} brushSettings={state.editor.tools.brush}
editingMask={Boolean(state.editor.maskEdit)} editingMask={Boolean(state.editor.maskEdit)}
maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"} maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"}
brushHint={brushHint}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds} transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget} transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch} dispatch={app.store.dispatch}

View File

@@ -19,10 +19,11 @@ export type BottomControlsIslandProps = {
maskViewMode?: MaskViewMode; maskViewMode?: MaskViewMode;
transformBounds?: Rect; transformBounds?: Rect;
transformTarget?: TransformTarget; transformTarget?: TransformTarget;
brushHint?: string;
dispatch: AppStore["dispatch"]; 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 zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x); const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y); 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" 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 ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} /> <BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : transformBounds && transformTarget ? ( ) : transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} /> <TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
@@ -46,3 +49,12 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
</div> </div>
); );
} }
function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) {
return (
<div className="flex items-center gap-3 px-4 text-center">
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium uppercase tracking-wide text-white/55">{tool}</span>
<span className="max-w-[34rem] text-sm font-medium text-white/80">{hint}</span>
</div>
);
}

View File

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

View File

@@ -27,6 +27,25 @@ export function canPreviewBrush(document: ImageDocument, editor: EditorState): b
return Boolean(resolveBrushTargetLayer(document, editor)); 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 { function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState): RasterLayer | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined; if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const editingMask = Boolean(editor.maskEdit); const editingMask = Boolean(editor.maskEdit);
@@ -146,10 +165,15 @@ function loadImage(source: string) {
} }
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined { 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) { for (const layer of layers) {
if (layer.id === layerId && layer.type === "raster") return layer; if (layer.id === layerId) return layer;
if (layer.type === "group") { if (layer.type === "group") {
const child = findRasterLayer(layer.children, layerId); const child = findLayer(layer.children, layerId);
if (child) return child; if (child) return child;
} }
} }

View File

@@ -2,9 +2,12 @@ import type { InteractionMode } from "@editor/tools";
import { isPanInteractionMode } from "@editor/tools"; import { isPanInteractionMode } from "@editor/tools";
import type { CanvasInputState } from "./useCanvasInput"; 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 (input.isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab"; 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"; return "cursor-default";
} }

View File

@@ -21,7 +21,7 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A
const maskLayerIds = collectMaskLayerIds(artboard.layers); const maskLayerIds = collectMaskLayerIds(artboard.layers);
context.save(); context.save();
context.translate(-artboard.bounds.x, -artboard.bounds.y); 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(); context.restore();
const url = canvas.toDataURL("image/png"); const url = canvas.toDataURL("image/png");
@@ -52,7 +52,7 @@ async function drawLayer(
context.globalAlpha *= layer.opacity; context.globalAlpha *= layer.opacity;
if (layer.type === "group") { 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(); context.restore();
return; return;
} }
@@ -101,6 +101,10 @@ async function drawMaskedLayer(
context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y); context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y);
} }
function renderStack(layers: readonly Layer[]) {
return [...layers].reverse();
}
function createArtboardCanvas(bounds: Rect) { function createArtboardCanvas(bounds: Rect) {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bounds.w)); canvas.width = Math.max(1, Math.round(bounds.w));