feat(tools): add brush raster painting
This commit is contained in:
@@ -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
105
view/canvas/brush.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user