diff --git a/commands/ids.ts b/commands/ids.ts index 0fe5bf5..e043654 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -22,6 +22,7 @@ export const commandIds = { selectionClear: "selection.clear", selectionAddLayer: "selection.addLayer", toolSetActive: "tool.setActive", + toolSetBrushSettings: "tool.setBrushSettings", toolEnterTemporaryPan: "tool.enterTemporaryPan", toolExitTemporaryPan: "tool.exitTemporaryPan", transformBegin: "transform.begin", diff --git a/commands/index.ts b/commands/index.ts index 5ad9c7c..b8dcd39 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -50,10 +50,10 @@ export type { CommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool"; +export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool"; export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; -export type { ToolSetActivePayload } from "./tool"; +export type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool"; export { viewportCommands, viewportPanCommand, diff --git a/commands/payloads.ts b/commands/payloads.ts index 92f71b0..5a5a4f7 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -21,7 +21,7 @@ import type { DocumentUngroupLayerPayload, } from "./document"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -import type { ToolSetActivePayload } from "./tool"; +import type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { ViewportFitArtboardPayload, @@ -55,6 +55,7 @@ export type CommandPayloads = { [commandIds.selectionClear]: void; [commandIds.selectionAddLayer]: SelectionAddLayerPayload; [commandIds.toolSetActive]: ToolSetActivePayload; + [commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload; [commandIds.toolEnterTemporaryPan]: void; [commandIds.toolExitTemporaryPan]: void; [commandIds.transformBegin]: TransformBeginPayload; diff --git a/commands/tool.test.ts b/commands/tool.test.ts index d9e14b5..c81f317 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createInitialAppState } from "@editor/initial-state"; -import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool"; +import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool"; describe("tool commands", () => { test("sets active tool", () => { const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" }); - expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } }); + expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: { color: "#111827", size: 8, hardness: 100 } }); + }); + + test("sets brush settings", () => { + const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 }); + + expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 }); }); test("enters and exits temporary pan", () => { @@ -13,7 +19,7 @@ describe("tool commands", () => { const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined); const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined); - expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } }); + expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 } }); expect(restored.editor.tools).toEqual(initial.editor.tools); }); }); diff --git a/commands/tool.ts b/commands/tool.ts index d77d244..d90f2f2 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -1,4 +1,4 @@ -import type { ToolId } from "@editor/tools"; +import type { BrushSettings, ToolId } from "@editor/tools"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -6,6 +6,8 @@ export type ToolSetActivePayload = { tool: ToolId; }; +export type ToolSetBrushSettingsPayload = Partial; + export const toolSetActiveCommand: Command = { id: commandIds.toolSetActive, name: "Set active tool", @@ -15,6 +17,7 @@ export const toolSetActiveCommand: Command = { editor: { ...state.editor, tools: { + ...state.editor.tools, activeTool: payload.tool, interactionMode: { type: "tool", tool: payload.tool }, }, @@ -23,6 +26,27 @@ export const toolSetActiveCommand: Command = { }, }; +export const toolSetBrushSettingsCommand: Command = { + id: commandIds.toolSetBrushSettings, + name: "Set brush settings", + execute({ state }, payload) { + return { + ...state, + editor: { + ...state.editor, + tools: { + ...state.editor.tools, + brush: { + color: payload.color ?? state.editor.tools.brush.color, + size: clampNumber(payload.size ?? state.editor.tools.brush.size, 1, 200), + hardness: clampNumber(payload.hardness ?? state.editor.tools.brush.hardness, 0, 100), + }, + }, + }, + }; + }, +}; + export const toolEnterTemporaryPanCommand: Command = { id: commandIds.toolEnterTemporaryPan, name: "Enter temporary pan", @@ -54,6 +78,7 @@ export const toolExitTemporaryPanCommand: Command = { editor: { ...state.editor, tools: { + ...state.editor.tools, activeTool: mode.previousTool, interactionMode: { type: "tool", tool: mode.previousTool }, }, @@ -62,4 +87,9 @@ export const toolExitTemporaryPanCommand: Command = { }, }; -export const toolCommands = [toolSetActiveCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command[]; +export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command[]; + +function clampNumber(value: number, min: number, max: number) { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); +} diff --git a/editor/tools.ts b/editor/tools.ts index aece09c..706f088 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -6,14 +6,22 @@ export type InteractionMode = | { type: "tool"; tool: ToolId } | { type: "temporary-pan"; previousTool: ToolId }; +export type BrushSettings = { + color: string; + size: number; + hardness: number; +}; + export type ToolState = { activeTool: ToolId; interactionMode: InteractionMode; + brush: BrushSettings; }; export const initialToolState: ToolState = { activeTool: "select", interactionMode: { type: "tool", tool: "select" }, + brush: { color: "#111827", size: 8, hardness: 100 }, }; export function isPanInteractionMode(interactionMode: InteractionMode): boolean { diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index cf3eaf5..4803070 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -27,7 +27,7 @@ describe("transform controls input", () => { ...createInitialAppState("Test").editor, viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, selection: { artboardId: "a1", layerIds: [] }, - tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const } }, + tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const }, brush: { color: "#111827", size: 8, hardness: 100 } }, }, }; const dispatched: unknown[] = []; @@ -55,7 +55,7 @@ describe("transform controls input", () => { ...createInitialAppState("Test").editor, viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, selection: { artboardId: "a1", layerIds: [] }, - tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const } }, + tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const }, brush: { color: "#111827", size: 8, hardness: 100 } }, }, }; const dispatched: unknown[] = []; diff --git a/view/App.tsx b/view/App.tsx index 3727174..459d7d7 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -81,8 +81,10 @@ export function App({ app }: AppProps) {
- {transformBounds && transformTarget ? ( + {activeTool === "brush" || activeTool === "eraser" ? ( + + ) : transformBounds && transformTarget ? ( ) : action === "pan" ? ( diff --git a/view/bottom-controls/BrushControls.tsx b/view/bottom-controls/BrushControls.tsx new file mode 100644 index 0000000..f981a46 --- /dev/null +++ b/view/bottom-controls/BrushControls.tsx @@ -0,0 +1,59 @@ +import { commandIds } from "@commands/ids"; +import type { AppStore } from "@editor/store"; +import type { BrushSettings, ToolId } from "@editor/tools"; +import { BottomControlDivider } from "./Divider"; +import { bottomControlLabelClass } from "./styles"; + +export type BrushControlsProps = { + tool: Extract; + settings: BrushSettings; + dispatch: AppStore["dispatch"]; +}; + +export function BrushControls({ tool, settings, dispatch }: BrushControlsProps) { + return ( +
+ {tool === "eraser" ? "Eraser" : "Brush"} + + {tool === "brush" ? ( + + ) : null} + + + +
+ ); +} diff --git a/view/canvas/brush.ts b/view/canvas/brush.ts index 28ea555..bba9000 100644 --- a/view/canvas/brush.ts +++ b/view/canvas/brush.ts @@ -25,8 +25,9 @@ export async function updateBrushSession(options: { store: AppStore; session: BrushSession; point: Vec2D; - color?: string; - size?: number; + color: string; + size: number; + hardness: number; }): Promise { const state = options.store.getState(); const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId); @@ -41,8 +42,9 @@ export async function updateBrushSession(options: { 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, + color: options.color, + size: options.size, + hardness: options.hardness, mode: options.session.mode, }); @@ -65,6 +67,7 @@ async function drawStroke(options: { to: Vec2D; color: string; size: number; + hardness: number; mode: "brush" | "eraser"; }) { const canvas = document.createElement("canvas"); @@ -75,8 +78,11 @@ async function drawStroke(options: { const image = await loadImage(options.source); context.drawImage(image, 0, 0, canvas.width, canvas.height); + const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; + context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; + context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 867225e..50d15af 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -103,7 +103,8 @@ export function useCanvasInput( if (brushSession.current) { const activeSessionId = brushSessionId.current; const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport); - void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => { + const settings = store.getState().editor.tools.brush; + void updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }).then((nextSession) => { if (brushSessionId.current === activeSessionId) brushSession.current = nextSession; }); event.preventDefault();