diff --git a/commands/document.test.ts b/commands/document.test.ts index 1ba4574..1dc7c11 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -18,6 +18,7 @@ import { documentSetLayerClippingMaskCommand, documentSetLayerLockedCommand, documentSetLayerVisibleCommand, + documentUpdateAssetSourceCommand, documentUngroupLayerCommand, } from "./document"; @@ -52,6 +53,17 @@ describe("document commands", () => { ]); }); + test("updates asset sources", () => { + const state = documentAddAssetCommand.execute( + { state: createInitialAppState("Test") }, + { asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "old", intrinsicSize: { w: 100, h: 50 } } }, + ); + + const next = documentUpdateAssetSourceCommand.execute({ state }, { assetId: "asset-1", source: "new" }); + + expect(next.document.assets[0]?.source).toBe("new"); + }); + test("adds image layers to artboards", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, diff --git a/commands/document.ts b/commands/document.ts index a735f68..3e181a9 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -1,7 +1,7 @@ import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; -import type { ArtboardId, LayerId } from "@core/id"; +import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ImageLayer } from "@core/image-layer"; import type { Layer } from "@core/layer"; import type { RasterLayer } from "@core/raster-layer"; @@ -43,6 +43,11 @@ export type DocumentAddAssetPayload = { asset: Asset; }; +export type DocumentUpdateAssetSourcePayload = { + assetId: AssetId; + source: string; +}; + export type DocumentAddImageLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; @@ -219,6 +224,20 @@ export const documentAddAssetCommand: Command = { }, }; +export const documentUpdateAssetSourceCommand: Command = { + id: commandIds.documentUpdateAssetSource, + name: "Update asset source", + execute({ state }, payload) { + return { + ...state, + document: { + ...state.document, + assets: state.document.assets.map((asset) => (asset.id === payload.assetId ? { ...asset, source: payload.source } : asset)), + }, + }; + }, +}; + export const documentAddImageLayerCommand: Command = { id: commandIds.documentAddImageLayer, name: "Add image layer", @@ -406,6 +425,7 @@ export const documentCommands = [ documentSetArtboardLockedCommand, documentRenameArtboardCommand, documentAddAssetCommand, + documentUpdateAssetSourceCommand, documentAddImageLayerCommand, documentAddRasterLayerCommand, documentAddGroupLayerCommand, diff --git a/commands/ids.ts b/commands/ids.ts index 1777619..0fe5bf5 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -6,6 +6,7 @@ export const commandIds = { documentSetArtboardLocked: "document.setArtboardLocked", documentRenameArtboard: "document.renameArtboard", documentAddAsset: "document.addAsset", + documentUpdateAssetSource: "document.updateAssetSource", documentAddImageLayer: "document.addImageLayer", documentAddRasterLayer: "document.addRasterLayer", documentAddGroupLayer: "document.addGroupLayer", diff --git a/commands/index.ts b/commands/index.ts index 39a02e2..5ad9c7c 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -18,6 +18,7 @@ export { documentSetLayerClippingMaskCommand, documentSetLayerLockedCommand, documentSetLayerVisibleCommand, + documentUpdateAssetSourceCommand, documentUngroupLayerCommand, } from "./document"; export type { @@ -38,6 +39,7 @@ export type { DocumentSetLayerClippingMaskPayload, DocumentSetLayerLockedPayload, DocumentSetLayerVisiblePayload, + DocumentUpdateAssetSourcePayload, DocumentUngroupLayerPayload, } from "./document"; export { historyCommands, historyRedoCommand, historyUndoCommand } from "./history"; diff --git a/commands/payloads.ts b/commands/payloads.ts index 4f88253..92f71b0 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -17,6 +17,7 @@ import type { DocumentSetLayerClippingMaskPayload, DocumentSetLayerLockedPayload, DocumentSetLayerVisiblePayload, + DocumentUpdateAssetSourcePayload, DocumentUngroupLayerPayload, } from "./document"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; @@ -38,6 +39,7 @@ export type CommandPayloads = { [commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload; [commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload; [commandIds.documentAddAsset]: DocumentAddAssetPayload; + [commandIds.documentUpdateAssetSource]: DocumentUpdateAssetSourcePayload; [commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload; [commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload; [commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload; diff --git a/editor/tools.ts b/editor/tools.ts index a8407ec..f0c2416 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -1,4 +1,4 @@ -export const availableToolIds = ["select", "crop", "pan"] as const; +export const availableToolIds = ["select", "crop", "brush", "pan"] as const; export type ToolId = (typeof availableToolIds)[number]; diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index 9caeb76..d694165 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -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": diff --git a/view/canvas/brush.ts b/view/canvas/brush.ts new file mode 100644 index 0000000..dba8b27 --- /dev/null +++ b/view/canvas/brush.ts @@ -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 { + 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((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; +} diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 82c7b4e..abf9a42 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -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"; } diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index f3ab340..7f16b5b 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -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(); 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, + }; +} diff --git a/view/toolLabels.ts b/view/toolLabels.ts index fcfaabe..a27872f 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -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":