diff --git a/package.json b/package.json index aae0a8a..40c8923 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@pixi/graphics-smooth": "^1.1.1", "@pixi/react": "^7.1.2", "@pixi/tilemap": "4.1.0", "@radix-ui/react-checkbox": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3892d79..6b48929 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@pixi/graphics-smooth': + specifier: ^1.1.1 + version: 1.1.1(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2)))) '@pixi/react': specifier: ^7.1.2 version: 7.1.2(rbk2pa5kjfujbi7gk4qxenqa5a) @@ -690,6 +693,13 @@ packages: peerDependencies: '@pixi/core': 7.4.2 + '@pixi/graphics-smooth@1.1.1': + resolution: {integrity: sha512-9xIFWZhHGEb6KCnyWL6TVPYG/QkF0YDM/yDU5EvjTQbaj/1cITrXtI5P3tBkB5H0DQi+8J8/QS38MjfqNEJAYQ==} + peerDependencies: + '@pixi/core': ^7.2.0 + '@pixi/display': ^7.2.0 + '@pixi/graphics': ^7.2.0 + '@pixi/graphics@7.4.2': resolution: {integrity: sha512-jH4/Tum2RqWzHGzvlwEr7HIVduoLO57Ze705N2zQPkUD57TInn5911aGUeoua7f/wK8cTLGzgB9BzSo2kTdcHw==} peerDependencies: @@ -3499,6 +3509,12 @@ snapshots: dependencies: '@pixi/core': 7.4.2 + '@pixi/graphics-smooth@1.1.1(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))))': + dependencies: + '@pixi/core': 7.4.2 + '@pixi/display': 7.4.2(@pixi/core@7.4.2) + '@pixi/graphics': 7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))) + '@pixi/graphics@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2))(@pixi/sprite@7.4.2(@pixi/core@7.4.2)(@pixi/display@7.4.2(@pixi/core@7.4.2)))': dependencies: '@pixi/core': 7.4.2 diff --git a/src/components/canvas/Canvas.tsx b/src/components/canvas/Canvas.tsx index da25e91..7a0b147 100644 --- a/src/components/canvas/Canvas.tsx +++ b/src/components/canvas/Canvas.tsx @@ -13,6 +13,7 @@ import { useTextures } from "@/hooks/useTextures"; import Blocks from "./Blocks"; import Cursor from "./Cursor"; +import SelectionBox from "./SelectionBox"; import Grid from "./Grid"; import CanvasBorder from "./CanvasBorder"; @@ -40,6 +41,7 @@ function Canvas() { const [holdingAlt, setHoldingAlt] = useState(false); const [oldTool, setOldTool] = useState("hand"); + const [selectionBoxBounds, setSelectionBoxBounds] = useState({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); const visibleArea = useMemo(() => { const blockSize = 16 * scale; @@ -64,14 +66,15 @@ function Canvas() { ); }, [blocks, visibleArea]); - const zoomToMousePosition = useCallback( + const zoom = useCallback( (newScale: number) => { + setScale(newScale); setCoords({ x: mousePosition.x - ((mousePosition.x - coords.x) / scale) * newScale, y: mousePosition.y - ((mousePosition.y - coords.y) / scale) * newScale, }); }, - [coords, mousePosition, scale, setCoords] + [coords, mousePosition, scale, setCoords, setScale] ); const updateCssCursor = useCallback(() => { @@ -148,39 +151,71 @@ function Canvas() { const onMouseMove = useCallback( (e: React.MouseEvent) => { - if (dragging) { - if (tool === "hand") { - setCoords((prevCoords) => ({ - x: prevCoords.x + e.movementX, - y: prevCoords.y + e.movementY, - })); - } - onToolUse(); - } - if (!stageContainerRef.current) return; + const oldMouseCoords = mouseCoords; + const rect = stageContainerRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; + const newMouseCoords = { + x: Math.floor((mouseX - coords.x) / (16 * scale)), + y: Math.floor((mouseY - coords.y) / (16 * scale)), + }; + setMousePosition({ x: mouseX, y: mouseY, }); - setMouseCoords({ - x: Math.floor((mouseX - coords.x) / (16 * scale)), - y: Math.floor((mouseY - coords.y) / (16 * scale)), - }); + setMouseCoords(newMouseCoords); + + if (dragging) { + switch (tool) { + case "hand": + setCoords((prevCoords) => ({ + x: prevCoords.x + e.movementX, + y: prevCoords.y + e.movementY, + })); + break; + case "move": { + setSelectionBoxBounds((prev) => ({ + minX: prev.minX + (newMouseCoords.x - oldMouseCoords.x), + minY: prev.minY + (newMouseCoords.y - oldMouseCoords.y), + maxX: prev.maxX + (newMouseCoords.x - oldMouseCoords.x), + maxY: prev.maxY + (newMouseCoords.y - oldMouseCoords.y), + })); + break; + } + case "rectangle-select": + setSelectionBoxBounds((prev) => ({ + ...prev, + maxX: mouseCoords.x + 1, + maxY: mouseCoords.y + 1, + })); + break; + } + + onToolUse(); + } }, - [dragging, coords, scale, tool, onToolUse, setCoords] + [dragging, coords, scale, tool, onToolUse, setCoords, setSelectionBoxBounds, mouseCoords] ); const onMouseDown = useCallback(() => { setDragging(true); onToolUse(); updateCssCursor(); - }, [onToolUse, updateCssCursor]); + + if (tool == "rectangle-select") { + setSelectionBoxBounds({ + minX: mouseCoords.x, + minY: mouseCoords.y, + maxX: mouseCoords.x, + maxY: mouseCoords.y, + }); + } + }, [onToolUse, updateCssCursor, tool, setSelectionBoxBounds, mouseCoords]); const onMouseUp = useCallback(() => { setDragging(false); @@ -192,10 +227,9 @@ function Canvas() { e.preventDefault(); const scaleChange = e.deltaY > 0 ? -0.1 : 0.1; const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32); - setScale(newScale); - zoomToMousePosition(newScale); + zoom(newScale); }, - [scale, zoomToMousePosition, setScale] + [scale, zoom] ); const onClick = useCallback(() => { @@ -208,15 +242,14 @@ function Canvas() { case "zoom": { const scaleChange = holdingAlt ? -0.1 : 0.1; const newScale = Math.min(Math.max(scale + scaleChange * scale, 0.1), 32); - setScale(newScale); - zoomToMousePosition(newScale); + zoom(newScale); break; } default: break; } - }, [tool, holdingAlt, scale, mouseCoords, blocks, zoomToMousePosition, setScale, setSelectedBlock]); + }, [tool, holdingAlt, scale, mouseCoords, blocks, setSelectedBlock, zoom]); const onKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -230,15 +263,21 @@ function Canvas() { setTool("hand"); break; case "2": - setTool("pencil"); + setTool("move"); break; case "3": - setTool("eraser"); + setTool("rectangle-select"); break; case "4": - setTool("eyedropper"); + setTool("pencil"); break; case "5": + setTool("eraser"); + break; + case "6": + setTool("eyedropper"); + break; + case "7": setTool("zoom"); break; case "Alt": @@ -327,6 +366,7 @@ function Canvas() { {settings.canvasBorder && } + {settings.grid && ( diff --git a/src/components/canvas/CanvasBorder.tsx b/src/components/canvas/CanvasBorder.tsx index e2fd32e..fc267c3 100644 --- a/src/components/canvas/CanvasBorder.tsx +++ b/src/components/canvas/CanvasBorder.tsx @@ -1,7 +1,7 @@ import { Graphics } from "@pixi/react"; interface Props { - canvasSize: CanvasSize; + canvasSize: BoundingBox; isDark: boolean; } diff --git a/src/components/canvas/SelectionBox.tsx b/src/components/canvas/SelectionBox.tsx new file mode 100644 index 0000000..2f889f2 --- /dev/null +++ b/src/components/canvas/SelectionBox.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef } from "react"; +import { useApp } from "@pixi/react"; +import { DashLineShader, SmoothGraphics } from "@pixi/graphics-smooth"; + +interface Props { + bounds: BoundingBox; + coords: Position; + scale: number; + isDark: boolean; +} + +const shader = new DashLineShader({ dash: 8, gap: 5 }); + +function SelectionBox({ bounds, coords, scale, isDark }: Props) { + const app = useApp(); + const selectionRef = useRef(); + + const drawSelection = () => { + if (!selectionRef.current) return; + const graphics = selectionRef.current; + graphics.clear(); + graphics.lineStyle({ width: 1, color: isDark ? 0xffffff : 0x000000, shader }); + graphics.drawRect( + bounds.minX * 16 * scale, + bounds.minY * 16 * scale, + (bounds.maxX - bounds.minX) * 16 * scale, + (bounds.maxY - bounds.minY) * 16 * scale + ); + }; + + useEffect(() => { + const graphics = new SmoothGraphics(); + selectionRef.current = graphics; + drawSelection(); + app.stage.addChild(graphics); + }, []); + + useEffect(() => { + if (!selectionRef.current) return; + const graphics = selectionRef.current; + + graphics.x = coords.x; + graphics.y = coords.y; + drawSelection(); + }, [coords]); + + useEffect(drawSelection, [bounds]); + + return null; +} + +export default SelectionBox; diff --git a/src/components/toolbar/index.tsx b/src/components/toolbar/index.tsx index 1886c3f..bcf5fc9 100644 --- a/src/components/toolbar/index.tsx +++ b/src/components/toolbar/index.tsx @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { EraserIcon, HandIcon, PencilIcon, PipetteIcon, ZoomInIcon } from "lucide-react"; +import { EraserIcon, HandIcon, MousePointer2Icon, PencilIcon, PipetteIcon, SquareDashedIcon, ZoomInIcon } from "lucide-react"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -33,6 +33,30 @@ function Toolbar() { + {/* Move */} + + + + + + + +

Move (2)

+
+
+ + {/* Rectangle Select */} + + + + + + + +

Rectangle Select (3)

+
+
+ {/* Pencil */} @@ -41,7 +65,7 @@ function Toolbar() { -

Pencil (2)

+

Pencil (4)

@@ -53,7 +77,7 @@ function Toolbar() { -

Eraser (3)

+

Eraser (5)

@@ -65,7 +89,7 @@ function Toolbar() { -

Eyedropper (4)

+

Eyedropper (6)

@@ -77,7 +101,7 @@ function Toolbar() { -

Zoom (5)

+

Zoom (7)

diff --git a/src/context/Canvas.tsx b/src/context/Canvas.tsx index c0ff596..6a3a67e 100644 --- a/src/context/Canvas.tsx +++ b/src/context/Canvas.tsx @@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useMemo, useState } from "react"; interface Context { stageSize: Dimension; - canvasSize: CanvasSize; + canvasSize: BoundingBox; blocks: Block[]; coords: Position; scale: number; diff --git a/src/types.d.ts b/src/types.d.ts index 46bcc44..ac60dc7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -10,7 +10,7 @@ interface Dimension { height: number; } -interface CanvasSize { +interface BoundingBox { minX: number; minY: number; maxX: number; @@ -21,7 +21,7 @@ interface Block extends Position { name: string; } -type Tool = "hand" | "pencil" | "eraser" | "eyedropper" | "zoom"; +type Tool = "hand" | "move" | "rectangle-select" | "pencil" | "eraser" | "eyedropper" | "zoom"; interface Settings { grid: boolean;