feat(tools): add brush raster painting

This commit is contained in:
syntaxbullet
2026-07-03 17:37:43 +02:00
parent 665f4a5340
commit 9fc0c18114
11 changed files with 188 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
import { Crop, Cursor, Hand } from "@phosphor-icons/react";
import { Crop, Cursor, Hand, PaintBrush } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, ToolId } from "@editor/tools";
@@ -43,6 +43,8 @@ function iconForTool(tool: ToolId) {
switch (tool) {
case "crop":
return Crop;
case "brush":
return PaintBrush;
case "pan":
return Hand;
case "select":

105
view/canvas/brush.ts Normal file
View File

@@ -0,0 +1,105 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { RasterLayer } from "@core/raster-layer";
import type { EditorState } from "@editor/state";
import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
previousPoint: Vec2D;
};
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
if (editor.tools.activeTool !== "brush") return undefined;
const layerId = 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 };
}
export async function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
color?: string;
size?: number;
}): Promise<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 };
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return { ...options.session, previousPoint: options.point };
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: options.color ?? "#111827",
size: options.size ?? 8,
});
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
return { ...options.session, previousPoint: options.point };
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
return {
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
};
}
async function drawStroke(options: {
source: string;
width: number;
height: number;
from: Vec2D;
to: Vec2D;
color: string;
size: number;
}) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(options.width));
canvas.height = Math.max(1, Math.round(options.height));
const context = canvas.getContext("2d");
if (!context) return options.source;
const image = await loadImage(options.source);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
context.strokeStyle = options.color;
context.lineWidth = options.size;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.moveTo(options.from.x, options.from.y);
context.lineTo(options.to.x, options.to.y);
context.stroke();
return canvas.toDataURL("image/png");
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load raster layer"));
image.src = source;
});
}
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
for (const layer of layers) {
if (layer.id === layerId && layer.type === "raster") return layer;
if (layer.type === "group") {
const child = findRasterLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}

View File

@@ -5,6 +5,6 @@ import type { CanvasInputState } from "./useCanvasInput";
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
if (input.isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && interactionMode.tool === "crop") return "cursor-crosshair";
if (interactionMode.type === "tool" && (interactionMode.tool === "crop" || interactionMode.tool === "brush")) return "cursor-crosshair";
return "cursor-default";
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, type RefObject } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { AppStore } from "@editor/store";
import { isPanInteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
@@ -11,6 +11,7 @@ import {
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "@input/index";
import { beginBrushSession, updateBrushSession, type BrushSession } from "./brush";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
@@ -28,6 +29,7 @@ export function useCanvasInput(
options: CanvasInputOptions,
): CanvasInputState {
const [isPanning, setIsPanning] = useState(false);
const brushSession = useRef<BrushSession>();
useEffect(() => {
const canvas = canvasRef.current;
@@ -59,6 +61,15 @@ export function useCanvasInput(
const handlePointerDown = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
const state = store.getState();
const brush = beginBrushSession(state.document, state.editor, viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport));
if (brush) {
brushSession.current = brush;
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
const transformed = transformHandler.pointerDown(inputEvent);
if (transformed) {
canvas.setPointerCapture(event.pointerId);
@@ -74,12 +85,12 @@ export function useCanvasInput(
return;
}
const state = store.getState();
const selectionToolActive = state.editor.tools.activeTool === "select" || state.editor.tools.activeTool === "crop";
const currentState = store.getState();
const selectionToolActive = currentState.editor.tools.activeTool === "select" || currentState.editor.tools.activeTool === "crop";
const selected = selectionToolActive && handleArtboardSelection({
event: inputEvent,
document: state.document,
viewport: state.editor.viewport,
document: currentState.document,
viewport: currentState.editor.viewport,
dispatch: store.dispatch,
});
if (selected) event.preventDefault();
@@ -87,6 +98,15 @@ export function useCanvasInput(
const handlePointerMove = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => {
brushSession.current = nextSession;
});
event.preventDefault();
return;
}
const transformed = transformHandler.pointerMove(inputEvent);
if (transformed) {
event.preventDefault();
@@ -99,6 +119,12 @@ export function useCanvasInput(
const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (brushSession.current) {
brushSession.current = undefined;
event.preventDefault();
return;
}
const transformed = transformHandler.pointerUp(inputEvent);
if (transformed) {
event.preventDefault();
@@ -144,3 +170,10 @@ export function useCanvasInput(
return { isPanning };
}
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,
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
};
}

View File

@@ -4,6 +4,8 @@ export function labelForTool(tool: ToolId): string {
switch (tool) {
case "crop":
return "Crop";
case "brush":
return "Brush";
case "pan":
return "Pan";
case "select":