feat: implement brush availability hints and enhance layer rendering order
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
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}
|
||||
|
||||
@@ -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 ? (
|
||||
<BrushHint tool={activeTool} hint={brushHint} />
|
||||
) : activeTool === "brush" || activeTool === "eraser" ? (
|
||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||
@@ -46,3 +49,12 @@ export function BottomControlsIsland({ viewport, visible, action, activeTool, br
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user